<?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>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>
    <item>
      <title>5 New React Native Features in 2026 (And 2 I'm Still Waiting For)</title>
      <dc:creator>Diven Rastdus</dc:creator>
      <pubDate>Wed, 26 Aug 2026 10:12:24 +0000</pubDate>
      <link>https://dev.to/astraedus/5-new-react-native-features-in-2026-and-2-im-still-waiting-for-220c</link>
      <guid>https://dev.to/astraedus/5-new-react-native-features-in-2026-and-2-im-still-waiting-for-220c</guid>
      <description>&lt;p&gt;If you last touched React Native a year ago, the best upgrades waiting for you aren't features you toggle. They're defaults you inherit. The bridge is gone, the New Architecture is the only architecture, and a pile of long-promised performance work now ships turned on.&lt;/p&gt;

&lt;p&gt;I build production apps on Expo and React Native, so I've shipped through this whole run of releases. Here are the 5 changes that actually changed how I work, and the 2 I'm still waiting on. First, the shape of the last two years:&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%2Ffbq26tqhlg715l3qjrrm.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%2Ffbq26tqhlg715l3qjrrm.png" alt="React Native release timeline from 0.76 to 0.87, showing the New Architecture going from default to the only option" width="800" height="941"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The New Architecture is the only architecture now
&lt;/h2&gt;

&lt;p&gt;For years the New Architecture (Fabric, TurboModules, no bridge) was an opt-in you toggled and prayed over. That era's over. It became the default for new apps in 0.76, and in 0.82 it became the only option. The escape-hatch flags are now ignored:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="c"&gt;# android/gradle.properties
# This meant something in 0.81. As of 0.82 it does nothing.
&lt;/span&gt;&lt;span class="py"&gt;newArchEnabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By 0.84 the legacy architecture code is compiled out of the binary by default, not just frozen. An interop layer still exists so older third-party libraries keep working, but the old runtime is on its way to deletion. The practical effect: synchronous native calls, concurrent React, and smaller binaries are just how apps work now, with no config to get wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Hermes V1 is the default engine
&lt;/h2&gt;

&lt;p&gt;Hermes was already the recommended JS engine. In 0.84 the rewritten Hermes V1 became the default on both iOS and Android, with no migration step on your side. You get faster startup and better memory behavior for free by upgrading.&lt;/p&gt;

&lt;p&gt;You don't have to trust me on which engine is running. Check it at runtime:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isHermes&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="o"&gt;!!&lt;/span&gt;&lt;span class="nb"&gt;global&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;HermesInternal&lt;/span&gt;&lt;span class="p"&gt;;&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Hermes:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;isHermes&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt; &lt;span class="c1"&gt;// true by default on 0.84+&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that logs &lt;code&gt;false&lt;/code&gt; after upgrading, something in your build config is overriding it. On a clean 0.84 project it's &lt;code&gt;true&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. iOS builds got dramatically faster
&lt;/h2&gt;

&lt;p&gt;This is the one my laptop is most grateful for. React Native for iOS now ships as precompiled binaries instead of building the whole core from source on every clean install. It landed experimental in 0.81 (up to 10x faster clean compiles) and became the default in 0.84.&lt;/p&gt;

&lt;p&gt;On the Expo side, SDK 54 shipped the same precompiled XCFrameworks. Their own RNTester clean build dropped from about 120 seconds to about 10 seconds on an M4 Max. If your CI clean-build times quietly halved this year, this is why. You don't opt in anymore. You just stop waiting.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. React 19 and a real React Compiler
&lt;/h2&gt;

&lt;p&gt;React 19 landed in React Native in 0.78, and the follow-on releases kept the version current (19.1, 19.2). The bigger deal is the React Compiler, which reached a stable v1.0 in October 2025 and works for React Native, not just the web.&lt;/p&gt;

&lt;p&gt;The compiler memoizes your components automatically, so a lot of manual &lt;code&gt;useMemo&lt;/code&gt; and &lt;code&gt;useCallback&lt;/code&gt; becomes noise you can delete:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The old ritual: memoize by hand or re-sort on every render&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sorted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useMemo&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;sortItems&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="c1"&gt;// With the compiler on: write the plain version, it memoizes for you&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sorted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sortItems&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;New Expo apps (SDK 54 and later) turn the compiler on by default. Read the caveats before you flip it on an existing app, but the direction's clear: hand-tuned memoization is becoming a thing you do rarely, not a habit.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The Strict TypeScript API is the default
&lt;/h2&gt;

&lt;p&gt;React Native's types used to be hand-maintained &lt;code&gt;.d.ts&lt;/code&gt; files that drifted from the actual code. As of 0.87 the public API is generated straight from React Native's own source, and the Strict TypeScript API is the default for every project.&lt;/p&gt;

&lt;p&gt;The visible consequence: reaching into internal paths is now a type error instead of a footgun.&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;// This used to "work" by importing from a private path. Now it's a type error:&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;SomeInternal&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-native/Libraries/Components/...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Import from the public surface, which is what the types describe:&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;View&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Pressable&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-native&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your codebase leans on deep imports, upgrading to 0.87 will surface them all at once. That's annoying for an afternoon and correct forever after.&lt;/p&gt;

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

&lt;p&gt;Two things are announced but not done, and both would remove real friction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The React Compiler as a true React-Native-wide default.&lt;/strong&gt; It's stable, and it's on by default for &lt;strong&gt;new Expo apps&lt;/strong&gt;. Everywhere else you opt in by hand, and worse, it's easy to think the compiler is active when the transform never actually ran in your Metro output. Until a bare &lt;code&gt;npx react-native init&lt;/code&gt; app ships with it on and verifiable, "stable" still comes with an asterisk for most of the ecosystem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Swift Package Manager on iOS.&lt;/strong&gt; SwiftPM support arrived in 0.87, but it's experimental and CocoaPods is still the default. The promise is real (no Ruby, no Bundler, no &lt;code&gt;pod install&lt;/code&gt; after every dependency change), and iOS tooling has needed this for years. I want the day CocoaPods is the legacy option, not the required one. We're not there yet.&lt;/p&gt;

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

&lt;p&gt;React Native's 2026 story isn't one flashy feature. It's the boring kind of progress that compounds: the New Architecture stopped being a gamble and became the floor, and the toolchain got faster underneath you. Most of these wins arrive by doing nothing more exciting than bumping your version and deleting code you used to need.&lt;/p&gt;

&lt;p&gt;So the biggest win is boring. Get onto a recent release, delete the manual memoization the compiler now handles, and stop opting into things that are already the default. The interesting work is the app you build on top, not the plumbing you no longer have to think about.&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>react</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>5 Next.js 16 Features That Actually Shipped (And 2 I'm Still Waiting For)</title>
      <dc:creator>Diven Rastdus</dc:creator>
      <pubDate>Mon, 24 Aug 2026 10:09:43 +0000</pubDate>
      <link>https://dev.to/astraedus/5-nextjs-16-features-that-actually-shipped-and-2-im-still-waiting-for-25id</link>
      <guid>https://dev.to/astraedus/5-nextjs-16-features-that-actually-shipped-and-2-im-still-waiting-for-25id</guid>
      <description>&lt;p&gt;Next.js 16 is the biggest architecture shift since the App Router: caching is now opt-in instead of implicit, &lt;code&gt;middleware.ts&lt;/code&gt; became &lt;code&gt;proxy.ts&lt;/code&gt;, and Turbopack is the default bundler for every app. If you upgrade for one reason, upgrade because you finally control when your data is cached instead of guessing.&lt;/p&gt;

&lt;p&gt;I run Next.js in production, and the caching model in 15 was the part I fought most. Half my &lt;code&gt;no-store&lt;/code&gt; and &lt;code&gt;revalidate&lt;/code&gt; lines were cargo cult, added to make a stale page go away without really knowing why it was stale. Next.js 16 kills that guessing game. Here are the five features that shipped and are worth your time, plus two things I wanted that aren't here yet.&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%2Fg4ckv34am61t2he2rpfu.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%2Fg4ckv34am61t2he2rpfu.png" alt="The Next.js 16 render path" width="800" height="891"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The diagram is the whole mental model: a request hits &lt;code&gt;proxy.ts&lt;/code&gt;, the route renders dynamically by default, and you opt specific pieces into caching. Everything below is a piece of that path.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Cache Components make caching explicit with "use cache"
&lt;/h2&gt;

&lt;p&gt;Caching in 16 is opt-in. Every page, layout, and API route runs at request time by default, and you cache the parts you choose with the &lt;code&gt;"use cache"&lt;/code&gt; directive. That's the opposite of the App Router's old implicit caching, where you often couldn't tell why a page went stale.&lt;/p&gt;

&lt;p&gt;In 15 you fought the cache by disabling 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;// Next 15: opt OUT of caching you didn't ask for&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dynamic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;force-dynamic&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;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;cache&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-store&lt;/span&gt;&lt;span class="dl"&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 16 you turn one config flag on and opt specific things IN:&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;// next.config.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nextConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;cacheComponents&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="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nx"&gt;nextConfig&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ProductList&lt;/span&gt;&lt;span class="p"&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;use cache&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;products&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findMany&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;List&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="sr"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="err"&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 compiler generates the cache key for you wherever &lt;code&gt;"use cache"&lt;/code&gt; appears. Under the hood this completes Partial Prerendering: the cached parts become a static shell that streams instantly, and the uncached parts (a logged-in user's cart, a live price) render per request as dynamic holes. Note the old &lt;code&gt;experimental.ppr&lt;/code&gt; and &lt;code&gt;experimental.dynamicIO&lt;/code&gt; flags are gone, folded into this one &lt;code&gt;cacheComponents&lt;/code&gt; option.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Turbopack is the default now, and it's finally stable
&lt;/h2&gt;

&lt;p&gt;Turbopack ships as the default bundler in Next.js 16, stable for both dev and production. Vercel measures 2 to 5 times faster production builds and up to 10 times faster Fast Refresh, with zero config. If you have a custom webpack setup, opt out per command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;next dev &lt;span class="nt"&gt;--webpack&lt;/span&gt;
next build &lt;span class="nt"&gt;--webpack&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 16.3 release pushed this further. Persistent filesystem caching for &lt;code&gt;next build&lt;/code&gt; is on by default, and Vercel reports build times dropping 2.3 to 5.5 times on their own sites when the cache is warm. Dev-server memory also drops sharply, up to 90 percent on large apps, because Turbopack now evicts its in-memory cache to disk instead of holding every visited route in RAM.&lt;/p&gt;

&lt;p&gt;One CI caveat that will bite you: the cache lives in &lt;code&gt;.next/cache&lt;/code&gt;, so your builds only get faster if you restore that directory between runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. middleware.ts is now proxy.ts
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;middleware.ts&lt;/code&gt; was renamed to &lt;code&gt;proxy.ts&lt;/code&gt;, and it runs on the Node.js runtime. The rename is about honesty: the file intercepts requests at your network boundary, so it should say so in its name.&lt;/p&gt;

&lt;p&gt;Migration is mechanical. Rename the file, rename the exported function, keep your logic:&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;// proxy.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;proxy&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;NextRequest&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;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/home&lt;/span&gt;&lt;span class="dl"&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;url&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;middleware.ts&lt;/code&gt; still works for Edge runtime cases, but it's deprecated and will be removed later. Rename it now while it's a one-line change.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. New cache-invalidation APIs give you read-your-writes
&lt;/h2&gt;

&lt;p&gt;Next.js 16 adds two new Server Action APIs and changes a third, so you control exactly how fresh your data is. The one I reach for most is &lt;code&gt;updateTag()&lt;/code&gt;, which expires a tag and reads fresh data in the same request:&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;updateTag&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/cache&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;saveProfile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Profile&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;updateTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`user-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// user sees their edit immediately&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;refresh()&lt;/code&gt; refreshes uncached data only, useful for a live counter after an action. And &lt;code&gt;revalidateTag()&lt;/code&gt; now takes a &lt;code&gt;cacheLife&lt;/code&gt; profile as a second argument for stale-while-revalidate behavior:&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;revalidateTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;blog-posts&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;max&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// serve stale, revalidate in the background&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The single-argument &lt;code&gt;revalidateTag('blog-posts')&lt;/code&gt; still runs but is deprecated. The rule I use: &lt;code&gt;updateTag&lt;/code&gt; when a user must see their own write, &lt;code&gt;revalidateTag&lt;/code&gt; when eventual consistency is fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. React Compiler support is stable
&lt;/h2&gt;

&lt;p&gt;The React Compiler integration is stable in 16, following the compiler's 1.0 release. Flip one flag and it auto-memoizes your components, which removes most hand-written &lt;code&gt;useMemo&lt;/code&gt; and &lt;code&gt;useCallback&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="c1"&gt;// next.config.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nextConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;reactCompiler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stable, yes, but read the caveat at the end before you flip it on. It's not on by default, because it relies on Babel and that slows your build. If build time is why you're hesitating, 16.3 added an experimental Rust port of the compiler that ran 20 to 50 percent faster in Vercel's tests, behind &lt;code&gt;experimental.turbopackRustReactCompiler&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Now the 2 things I'm still waiting for
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A guided caching story.&lt;/strong&gt; Cache Components solved the "why is this stale" problem, but handed me a new one: deciding what to cache is entirely manual. The compiler generates keys, it doesn't tell me which boundaries are safe to cache. I want a lint rule or a codemod that flags cacheable server components, the way the React Compiler flags un-memoizable code. Right now the model is powerful and the guidance is thin.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Rust compiler, stable and on by default.&lt;/strong&gt; The whole point of the React Compiler is to delete manual memoization. But the default path is Babel, which taxes every build, so most teams leave it off. The fast native compiler exists and is experimental. Until it's stable and default, the compiler stays a "someday" checkbox for a lot of real apps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you upgrade: the gotchas
&lt;/h2&gt;

&lt;p&gt;The codemod handles most of it, but these bit me or would have. Read this list first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Node.js 20.9+ required.&lt;/strong&gt; Node 18 is no longer supported. Check your CI image.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Request APIs are async.&lt;/strong&gt; &lt;code&gt;params&lt;/code&gt;, &lt;code&gt;searchParams&lt;/code&gt;, &lt;code&gt;cookies()&lt;/code&gt;, &lt;code&gt;headers()&lt;/code&gt;, and &lt;code&gt;draftMode()&lt;/code&gt; must be awaited. The &lt;code&gt;next-async-request-api&lt;/code&gt; codemod covers most call sites.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restore &lt;code&gt;.next/cache&lt;/code&gt; in CI.&lt;/strong&gt; Miss this and every build is a cold build, so you lose the Turbopack speedup entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;next/image&lt;/code&gt; defaults changed.&lt;/strong&gt; &lt;code&gt;qualities&lt;/code&gt; now defaults to &lt;code&gt;[75]&lt;/code&gt; and local IP optimization is blocked. Audit any image that relied on the old defaults.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;next lint&lt;/code&gt; is gone.&lt;/strong&gt; Run Biome or ESLint directly; &lt;code&gt;next build&lt;/code&gt; no longer lints.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Next.js 16 asks you to say what you mean. You opt into caching, you name your network boundary, you turn the compiler on yourself. That's more upfront work than 15, and it's the right trade, because the implicit magic was exactly what made the framework hard to reason about.&lt;/p&gt;

&lt;p&gt;Concrete upgrade path if you're on 15:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;code&gt;npx @next/codemod@canary upgrade latest&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Run the &lt;code&gt;next-async-request-api&lt;/code&gt; and &lt;code&gt;middleware-to-proxy&lt;/code&gt; codemods.&lt;/li&gt;
&lt;li&gt;Restore &lt;code&gt;.next/cache&lt;/code&gt; in CI.&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;cacheComponents: true&lt;/code&gt; and add &lt;code&gt;"use cache"&lt;/code&gt; to the routes you actually want cached.&lt;/li&gt;
&lt;li&gt;Ship, then watch build times drop on every run after.&lt;/li&gt;
&lt;/ol&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>nextjs</category>
      <category>react</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Spaced Repetition Isn't Just for Flashcards. It Works for Motor Skills Too.</title>
      <dc:creator>Diven Rastdus</dc:creator>
      <pubDate>Sun, 23 Aug 2026 12:10:02 +0000</pubDate>
      <link>https://dev.to/astraedus/spaced-repetition-isnt-just-for-flashcards-it-works-for-motor-skills-too-2of3</link>
      <guid>https://dev.to/astraedus/spaced-repetition-isnt-just-for-flashcards-it-works-for-motor-skills-too-2of3</guid>
      <description>&lt;p&gt;Anki works by scheduling a flashcard's next review for right before you'd forget it. That single trick, expanding intervals timed against your own forgetting curve, is why spaced repetition apps beat "just review everything every day." Almost nobody applies the same math to physical skills. A chord shape, a scale fingering, a paradiddle: these decay on a forgetting curve too, and motor learning research says so pretty clearly. I spent a chunk of this year building a practice app for piano, guitar, and drums, and the thing that actually moved the needle for daily practice wasn't a nicer metronome. It was treating "what should I practice next" as a scheduling problem instead of a discipline problem.&lt;/p&gt;

&lt;p&gt;Two failures explain most people who quit an instrument. They sit down, open a songbook or a random lesson, and don't know what to actually work on tonight. Or they do know, get it right once, and never see it again until it's gone. Both have names in the literature, and both have boring, buildable fixes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Knowing the rule and doing the thing are different systems
&lt;/h2&gt;

&lt;p&gt;You can understand exactly how to keep your wrist loose on a big jump and still play it with a stiff wrist. That's not a failure of understanding. Declarative memory (facts, rules, "keep your thumb relaxed") lives in the hippocampus and prefrontal cortex. Procedural memory (the actual automatic execution) lives in the basal ganglia and cerebellum. Research with amnesiac patients confirmed these are genuinely separate systems: patients acquired motor skills they had no conscious memory of learning.&lt;/p&gt;

&lt;p&gt;That distinction matters for anyone building or running a self-directed practice regimen, because it kills a very common instinct: read more, watch another video, think harder about the technique while playing. None of that moves a skill from the declarative system into the procedural one. Only reps, done with attention on the actual sensorimotor feedback, do that. The Fitts and Posner model calls this the cognitive-to-associative-to-autonomous progression. Getting a single new technique to the point where it holds up under real playing takes days to weeks of daily reps. Getting a whole instrument to autonomous, don't-have-to-think-about-it fluency takes years. Neither timeline shortens because you understood the concept faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cramming feels productive. The data says it isn't.
&lt;/h2&gt;

&lt;p&gt;Distributed practice beats massed practice for retention. Shorter sessions spread across more days beat one marathon Sunday session, and a 1999 meta-analysis (Donovan &amp;amp; Radosevich) put the effect at d = 0.46, roughly half a standard deviation, for the same total practice time. The mechanism for an instrument is straightforward: each night's sleep is a consolidation event. NREM2 sleep spindles are when the motor trace from that day actually gets written down. Five sessions of twenty minutes across five nights buys four more consolidation events than one hundred-minute session on a Sunday, using the exact same number of minutes.&lt;/p&gt;

&lt;p&gt;Interleaving beats blocking for a related reason, and it feels worse while it's happening. A study with advanced clarinetists found interleaved practice, alternating between exercises instead of exhausting one before moving to the next, produced significantly better day-two retention (p = 0.02), even though 78% of participants preferred blocked practice because it felt more fluent in the moment. That's the fluency illusion: feeling good during practice and actually retaining the material are not the same measurement.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that surprised me: the gains are in the rest, not the rep
&lt;/h2&gt;

&lt;p&gt;Two studies (Bönstrup et al. 2020, Buch et al. 2021) found that most within-session motor learning happens during the ten-second pauses between practice blocks, not during the active reps. The hippocampus replays the sequence you just practiced, compressed and sped up, during that rest window, and the amount of replay tracks with how much you improve. Early motor learning consolidates roughly four times faster in these micro-rests than it does overnight. Playing sixty seconds, resting ten, and playing again beats ninety continuous seconds of the same material. Left to instinct, almost nobody builds in the pause on purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Coding the forgetting curve for a physical skill
&lt;/h2&gt;

&lt;p&gt;The app I built, Music Practice, is free and open source, and this is where the research turned into actual code instead of a blog post's worth of advice. The curriculum is a real prerequisite graph rather than a leveled course: 32 piano nodes, 36 guitar, 20 drums, each one gated on the nodes before it. That answers "what's next" honestly, with no guessing about whether you're ready.&lt;/p&gt;

&lt;p&gt;The part that answers "what did I forget" is a small spaced-retrieval queue sitting behind the tree. Every learned skill gets enqueued, and each successful review pushes it further out along an expanding ladder:&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;REVIEW_INTERVALS_DAYS&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;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;14&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;advanceReview&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;ReviewMap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;nodeId&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;now&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="nx"&gt;ReviewMap&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;prev&lt;/span&gt; &lt;span class="o"&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;nodeId&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;prev&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;review&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;nextIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;intervalIndex&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;REVIEW_INTERVALS_DAYS&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;1&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="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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;nodeId&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;dueAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;addDaysIso&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;intervalDays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nextIndex&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="na"&gt;intervalIndex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;nextIndex&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;A skill comes due one day after you first learn it. Review it successfully and it comes back in three days, then seven, then fourteen, then it just sits on the fourteen-day cadence forever. The ladder only ever moves forward. There is no Anki-style lapse step that dumps a shaky skill back to day one: a review you don't actually nail just leaves the skill sitting in the queue, still due, until you get a clean rep. Version one keeps the failure case deliberately simple. Nothing here is instrument-specific. The functions take dates in as arguments instead of calling &lt;code&gt;Date.now()&lt;/code&gt; internally. That makes the whole scheduler pure and trivial to test, and it would work identically for vocabulary, code katas, or physical therapy exercises. A forgetting curve is a forgetting curve whether the memory is a fact or a finger movement.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other counterintuitive number: aim for 30% wrong
&lt;/h2&gt;

&lt;p&gt;A 2023 preprint (Hoppe et al., not yet peer-reviewed, so treat it as suggestive rather than settled) found motor learning is maximized around a 70% success rate, 30% errors. Succeed more than about 85% of the time and there's no error signal left to learn from. Succeed less than about 60% of the time and the errors turn into noise instead of a clean, correctable pattern. If a drill feels easy, it's probably wasted time. If it feels close to impossible, it's probably also wasted time. The zone in between is uncomfortable but not hopeless, and that discomfort is doing real work.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this actually means if you're building or using a practice tool
&lt;/h2&gt;

&lt;p&gt;None of this is about talent. Sequencing and retention are the two real design problems in self-directed skill practice, and neither one gets solved by adding more content, more videos, more songs to learn. A prerequisite graph solves "what's next." An expanding-interval queue solves "what did I forget." Both are ordinary code, not insight, and both beat good intentions by a wide margin, because good intentions don't survive a Tuesday when you're tired and can't remember what you were even working on last time.&lt;/p&gt;

&lt;p&gt;If you want to see the whole thing running, the skill tree, the BPM-laddered drills, the review queue, it's live and free at &lt;a href="https://music.raeduslabs.com" rel="noopener noreferrer"&gt;music.raeduslabs.com&lt;/a&gt;, source at &lt;a href="https://github.com/astraedus/piano" rel="noopener noreferrer"&gt;github.com/astraedus/piano&lt;/a&gt;, MIT licensed.&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>showdev</category>
      <category>webdev</category>
      <category>music</category>
      <category>learning</category>
    </item>
    <item>
      <title>How to Give Your AI Agents a Memory That Actually Works</title>
      <dc:creator>Diven Rastdus</dc:creator>
      <pubDate>Fri, 21 Aug 2026 10:11:14 +0000</pubDate>
      <link>https://dev.to/astraedus/how-to-give-your-ai-agents-a-memory-that-actually-works-1l26</link>
      <guid>https://dev.to/astraedus/how-to-give-your-ai-agents-a-memory-that-actually-works-1l26</guid>
      <description>&lt;p&gt;Your AI agent forgets everything the moment the conversation ends. The fix isn't a bigger context window. Real agent memory is a deliberate loop. A &lt;strong&gt;write path&lt;/strong&gt; distills each turn into durable storage. A &lt;strong&gt;read path&lt;/strong&gt; retrieves the few relevant pieces back into context before the next reply. Get the loop right and the agent remembers what matters. Skip it and you've got a very expensive goldfish.&lt;/p&gt;

&lt;p&gt;I run a file-based memory system for a fleet of long-running agents. The bugs below aren't hypothetical. The worst one cost me a week: the agent had 200 correct facts on disk and still forgot a decision I'd told it about, because the retrieval step never fired. That is the failure this article is really about.&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%2F5fj69l8ptzi6up16pkbk.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%2F5fj69l8ptzi6up16pkbk.png" alt="Agent memory architecture: the read path retrieves and injects, the write path distills and persists, around a finite context window" width="799" height="630"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The context window is not memory
&lt;/h2&gt;

&lt;p&gt;A context window is a buffer, not a memory. It holds the current conversation, the LLM reasons over it, and everything past the token limit gets evicted at the edge. Nothing persists. The next session starts blank.&lt;/p&gt;

&lt;p&gt;People reach for the obvious fix first: a longer context. Just paste the whole history back in every turn. That breaks in three ways at once. It gets slow, because attention cost grows with length. It gets expensive, because you pay for every token every turn. And it gets &lt;em&gt;dumber&lt;/em&gt;, because the signal you need is buried under thousands of tokens of irrelevant history, and the model's recall sags in the middle of a long prompt.&lt;/p&gt;

&lt;p&gt;That third one surprises people. A bigger window is not the same as a better one.&lt;/p&gt;

&lt;p&gt;Memory is the opposite move. Instead of stuffing everything in, you store things outside the window and pull back only the few that matter right now.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four kinds of memory an agent needs
&lt;/h2&gt;

&lt;p&gt;Agents need four distinct stores, and lumping them together is why most homegrown memory feels broken. This split is not something I made up. It comes from the CoALA line of work on cognitive architectures for language agents, which borrows the categories straight from human memory research. Frameworks like Letta and LangGraph now ship their own variations of it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Working memory&lt;/strong&gt; is the context window itself. Short-lived, holds the active task.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Episodic memory&lt;/strong&gt; is time-stamped events: what happened, which tools ran, what the user said on Tuesday.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Semantic memory&lt;/strong&gt; is durable facts stripped of their timeline: user preferences, domain rules, distilled knowledge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Procedural memory&lt;/strong&gt; is how-to: skills, routines, and policies the agent learned to apply.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The reason to split them is retrieval. When a user asks "what did we decide about the pricing page?", that is an episodic lookup. When the agent needs to know "this user always wants TypeScript examples", that is semantic. Different questions hit different stores with different scoring. One flat blob can't serve both.&lt;/p&gt;

&lt;h2&gt;
  
  
  The write path: distill, don't dump
&lt;/h2&gt;

&lt;p&gt;The write path runs after each reply, and its job is to throw most of the turn away. Persisting the raw transcript is the beginner mistake. You end up with a store full of "ok", "thanks", and restated context that pollutes every future retrieval. Distill instead: extract the durable claim, drop the rest.&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;time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;distill_and_store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;turn_user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;turn_agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;After a reply, extract what is worth keeping. Drop the raw turn.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;facts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extract_facts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;turn_user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;turn_agent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# a cheap LLM call
&lt;/span&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;facts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&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="n"&gt;f&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="c1"&gt;# "episodic" | "semantic" | "procedural"
&lt;/span&gt;            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;            &lt;span class="c1"&gt;# the distilled claim, not the transcript
&lt;/span&gt;            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;importance&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;importance&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="c1"&gt;# 0..1, how load-bearing is this fact
&lt;/span&gt;        &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;extract_facts&lt;/code&gt; call is the whole game. Prompt a small model to pull out only standalone, reusable claims: "user is on the free tier", "deploy step requires the staging flag first". A good distiller turns a 500-token exchange into two 12-token facts. That compression is what keeps retrieval sharp months later.&lt;/p&gt;

&lt;p&gt;One more move earns its keep: promote episodic memories into semantic ones. A fact that stays true without its original context ("the user prefers dark mode") graduates to the semantic store, and the raw episode gets dropped. That is the dashed "distill" arrow in the diagram.&lt;/p&gt;

&lt;h2&gt;
  
  
  The read path: retrieval is the hard part
&lt;/h2&gt;

&lt;p&gt;The read path is where memory lives or dies, and almost everyone underbuilds it. Writing is easy. Deciding &lt;em&gt;which three of ten thousand stored facts&lt;/em&gt; belong in the prompt right now is the real problem. You can't inject them all, so you score and take the top-k.&lt;/p&gt;

&lt;p&gt;The scoring that works is a weighted blend, not pure vector similarity:&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;def&lt;/span&gt; &lt;span class="nf"&gt;score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;memory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query_embedding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;relevance&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;cosine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;memory&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;embedding&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;query_embedding&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# is it on-topic
&lt;/span&gt;    &lt;span class="n"&gt;recency&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.98&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;memory&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ts&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;3600&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# decays by the hour
&lt;/span&gt;    &lt;span class="n"&gt;importance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;memory&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;importance&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;                            &lt;span class="c1"&gt;# how load-bearing
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;relevance&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;recency&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;importance&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;recall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;embed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;ranked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&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;return&lt;/span&gt; &lt;span class="n"&gt;ranked&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;k&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;embed&lt;/code&gt;, &lt;code&gt;cosine&lt;/code&gt;, and &lt;code&gt;store&lt;/code&gt; are yours to wire up. Any vector database works, and for a few thousand memories a flat scan like this is fine before you reach for an index. The scoring is the part that matters.&lt;/p&gt;

&lt;p&gt;Pure semantic similarity fails on its own. It happily surfaces a highly relevant fact from three months ago while burying the thing the user told you ten minutes back. Recency and importance are the correction. Tune the weights to your app: a support bot leans on recency, a knowledge assistant leans on relevance.&lt;/p&gt;

&lt;p&gt;Then you inject, and you inject &lt;em&gt;little&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build_prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;memories&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;recall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&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;- &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&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;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;memories&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&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;Relevant memory:&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;context&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;User: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five distilled facts under a couple hundred tokens beat the whole history in nearly every case I've measured. Managed layers like Mem0 report roughly 90% fewer tokens versus full-context prompting, with lower latency, for exactly this reason. You're not sending less because you're cheap. You're sending less because less is what keeps the model sharp.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure mode nobody mentions: memory that never gets read
&lt;/h2&gt;

&lt;p&gt;The bug that cost me the most was not a bad write. It was a store full of correct memories that never surfaced. The write path succeeded, the facts sat on disk, and retrieval never pulled them because the scoring or the trigger was off. From the outside it looks identical to having no memory at all.&lt;/p&gt;

&lt;p&gt;This is why the diagram puts the read path on top. Build retrieval first. A memory you can't retrieve on demand isn't memory, it's a log file you're paying to store. So before you write a single distiller, prove the read path end to end:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Write one durable fact to the store.&lt;/li&gt;
&lt;li&gt;Start a completely fresh session, empty context.&lt;/li&gt;
&lt;li&gt;Ask a question that should need that fact.&lt;/li&gt;
&lt;li&gt;Assert the fact actually appears in the assembled prompt, not just that the answer looks right.&lt;/li&gt;
&lt;li&gt;Change the fact, and confirm the old value stops surfacing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If step 4 fails, your retrieval is broken, and no amount of clever writing will save it. That test is the first thing I build now, and it would have saved me that week.&lt;/p&gt;

&lt;p&gt;The same discipline catches the other quiet killer: stale memory. A stored fact that named a file, a price, or a flag that has since changed will confidently poison a future answer. Give memories a way to be updated or expired, and treat a recalled fact as &lt;em&gt;what was true when written&lt;/em&gt;, not gospel.&lt;/p&gt;

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

&lt;p&gt;Memory is a loop, not a store. Distill on the way in so the store stays clean, and score on the way out so the prompt stays sharp. Keep the four stores separate, because a question about "what happened Tuesday" and a question about "what this user always wants" need different shelves. And whatever you do, build the read path before the write path. Retrieval is where memory actually fails, so it is the part you should be able to prove first. Get that loop right and your agent stops re-introducing itself every morning, and starts behaving like something that was paying attention.&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>webdev</category>
      <category>llm</category>
    </item>
    <item>
      <title>5 AI Agent Features That Define 2026 (And 2 I'm Still Waiting For)</title>
      <dc:creator>Diven Rastdus</dc:creator>
      <pubDate>Wed, 19 Aug 2026 10:12:42 +0000</pubDate>
      <link>https://dev.to/astraedus/5-ai-agent-features-that-define-2026-and-2-im-still-waiting-for-2bef</link>
      <guid>https://dev.to/astraedus/5-ai-agent-features-that-define-2026-and-2-im-still-waiting-for-2bef</guid>
      <description>&lt;p&gt;The five features that define AI agents in 2026 are a shared tool standard (MCP), prompt caching, computer use, background subagents, and agent-to-agent messaging. None of them are the model itself. They are the plumbing that turns a clever text generator into something that does actual work.&lt;/p&gt;

&lt;p&gt;I run agents in production every day. For most of 2024 an "agent" meant a model stuck in a &lt;code&gt;while&lt;/code&gt; loop, and it broke the moment it touched the real world. In 2026 that stopped being true, and it had almost nothing to do with the models getting smarter. It was the layers underneath.&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%2Fiextpcxqt4p5gr1knvwj.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%2Fiextpcxqt4p5gr1knvwj.png" alt="The 2026 AI agent stack: foundation model, then MCP, prompt caching, computer use, subagents, and A2A, with durable memory and long-horizon autonomy still missing" width="800" height="1086"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here are the five that changed how I build, and the two I'm still waiting on.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. MCP became the universal tool layer
&lt;/h2&gt;

&lt;p&gt;MCP turned connecting an agent to a tool into one open standard, instead of a bespoke integration per vendor. That's the single biggest shift of 2026. The Model Context Protocol (MCP), which Anthropic released in November 2024, does for agent tools what USB did for peripherals: write the tool once, and any model can call it.&lt;/p&gt;

&lt;p&gt;The adoption is not a rumor. OpenAI adopted MCP in March 2025. In December 2025 Anthropic donated it to the Agentic AI Foundation, a new fund under the Linux Foundation, so it's now vendor-neutral infrastructure rather than one company's project. A tool server is tiny:&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;mcp.server.fastmcp&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastMCP&lt;/span&gt;

&lt;span class="n"&gt;mcp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastMCP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;weather&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&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;get_forecast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;city&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Return today&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s forecast for a city.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;fetch_weather&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;city&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That server now works with Claude, ChatGPT, and anything else that speaks MCP. Before this, I wrote the same tool three times. Now I write it once and forget which model is on the other end.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Prompt caching made the agent loop affordable
&lt;/h2&gt;

&lt;p&gt;Agents got cheap enough to run in loops because providers now let you reuse a cached prompt prefix for roughly a tenth of the price. An agent re-sends the same giant system prompt (tools, instructions, context) on every single turn. Paying full price for that on turn 40 was the hidden tax that killed long-running agents.&lt;/p&gt;

&lt;p&gt;By 2026 all three major providers discount cached input by up to 90%. On Anthropic you mark the stable part of the prompt once:&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;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;system&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;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;text&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;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;BIG_SYSTEM_PROMPT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;# tools + instructions + docs
&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="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;ephemeral&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="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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;do the next step&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You pay a small write premium the first time (1.25x on the 5-minute tier), then every later turn reads that prefix at 0.1x. A 40-turn agent run that used to feel reckless now costs pocket change. This is the feature nobody puts on a launch slide, and it's the one that quietly unlocked everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Computer use turned agents into hands, not just mouths
&lt;/h2&gt;

&lt;p&gt;Agents can now see a screen and operate it directly, so any app with a UI is reachable even when it has no API. Claude's computer-use launch in October 2024 was the inflection point. Anthropic's own benchmarks show the jump: computer-use task completion on OSWorld climbed from about 15% at launch to over 70% in 2026.&lt;/p&gt;

&lt;p&gt;You give the model a screen and a set of physical actions:&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;tools&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;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;computer_20250124&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# use the current tool version from the docs
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&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;computer&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;display_width_px&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1280&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;display_height_px&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}]&lt;/span&gt;
&lt;span class="c1"&gt;# The model replies with actions: screenshot, click(x, y), type("...").
# You execute each one, send back a fresh screenshot, and repeat.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I use this to drive a real browser with our own logins. It reads the page, decides what to click, and adapts when the layout moves. That's the difference from old RPA scripts, which shattered the first time a button shifted ten pixels.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Background subagents made parallelism the default
&lt;/h2&gt;

&lt;p&gt;The best agent systems in 2026 stopped being one model in one loop and became many agents running at once, in the background. Instead of a human running tasks one after another, you fan out a fleet, each subagent with its own context window and its own tools.&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="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run_agent&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="c1"&gt;# each subagent: isolated context, its own tools, its own model
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&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="n"&gt;results&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;gather&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="nf"&gt;run_agent&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="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;tasks&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both Claude Code and Codex shipped this: Codex spins up parallel cloud sandboxes and returns pull requests, and background sessions now survive a closed laptop. I lean on this constantly. A review that would take one agent an hour of sequential reading becomes ten agents reading in parallel, and I only hold the synthesis in context. Parallelism is free in a digital medium, and 2026 is the year the tools finally made it easy.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. A2A let agents talk to other agents
&lt;/h2&gt;

&lt;p&gt;A2A is a standard for agents to discover and call each other across vendors, the way MCP standardized tools. It's the newest layer in the 2026 stack. Google's Agent2Agent (A2A) protocol, announced in April 2025 and donated to the Linux Foundation, reached a stable v1.0 in early 2026 with more than 150 organizations backing it.&lt;/p&gt;

&lt;p&gt;An agent advertises itself with an Agent Card, a small manifest other agents can read:&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;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"billing-agent"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Answers invoice and payment questions"&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://api.acme.com/a2a"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"version"&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.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"capabilities"&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;"streaming"&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="nl"&gt;"skills"&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;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"lookup_invoice"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Look up an invoice"&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;MCP connects an agent to tools. A2A connects an agent to other agents. It's early, but the shape of a real multi-agent internet is now visible.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Durable native memory.&lt;/strong&gt; Every serious agent I run still fakes memory with a bolt-on: a vector database, or a folder of markdown files I re-inject each session. It works, but it's scaffolding I built by hand. No provider ships memory that survives across sessions and actually generalizes what it learned. Until that's native, "my agent remembers you" is a feature you engineer, not one you turn on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trustworthy long-horizon autonomy.&lt;/strong&gt; Agents are brilliant for twenty steps and drift on step two hundred. Errors compound, the plan wanders, and the only reliable fix is a human checking the work. I still verify every meaningful agent output against a real source of truth, never the agent's own "done." Real autonomy means the agent catches its own drift. We are not there.&lt;/p&gt;

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

&lt;p&gt;The model got the headlines in 2026. The progress that changed my day-to-day was the stack around it: a tool standard, cheap loops, real hands, parallel execution, and agents that talk to each other. You don't need to adopt all five. Pick the one layer that removes your biggest friction (usually MCP or caching) and add it to whatever you're already building. That's where the real gains are hiding.&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 -&amp;gt; &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>webdev</category>
      <category>llm</category>
    </item>
  </channel>
</rss>
