<?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: Lara Mateo</title>
    <description>The latest articles on DEV Community by Lara Mateo (@rinava).</description>
    <link>https://dev.to/rinava</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%2F4001690%2F2817c680-7950-49e5-8525-815d15791936.jpg</url>
      <title>DEV Community: Lara Mateo</title>
      <link>https://dev.to/rinava</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rinava"/>
    <language>en</language>
    <item>
      <title>Nobody audits their OpenAI invoice</title>
      <dc:creator>Lara Mateo</dc:creator>
      <pubDate>Fri, 14 Aug 2026 01:56:01 +0000</pubDate>
      <link>https://dev.to/rinava/nobody-audits-their-openai-invoice-2n5i</link>
      <guid>https://dev.to/rinava/nobody-audits-their-openai-invoice-2n5i</guid>
      <description>&lt;p&gt;Every team running LLMs in production has two numbers for last month's spend: the one in their tracking tool, and the one on the invoice the provider sent. They almost never match, and the reasons are documented: providers report cached tokens differently, the community pricing data is an estimate by its own admission, the tools themselves treat deltas under roughly 10% as normal, and the calls you never instrumented don't show up in your numbers at all.&lt;/p&gt;

&lt;p&gt;I've spent the past few weeks reading up on this, and the surprise was where the evidence lives. It's mostly in the docs of the tools doing the estimating, written against their own interest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the numbers diverge
&lt;/h2&gt;

&lt;h3&gt;
  
  
  OpenAI and Anthropic report cached tokens differently
&lt;/h3&gt;

&lt;p&gt;OpenAI folds cache reads into the reported input token count. Anthropic reports cache fields separately from regular input. Both choices are documented and both are reasonable, but any tool that computes cost through a single path across both providers will be wrong on one of them. This isn't theoretical: Langfuse issue #12306, opened February 2026, Anthropic cache tokens double-counted, roughly 2x inflated cost. LiteLLM has its own family of cache accounting issues.&lt;/p&gt;

&lt;h3&gt;
  
  
  The pricing registries call themselves estimates
&lt;/h3&gt;

&lt;p&gt;Pydantic maintains genai-prices, a community pricing registry of the kind most estimation tools depend on. Its README has a warning section literally titled "these prices will not be 100% accurate". Providers don't publish exact pricing for the direct OpenAI, Anthropic and Gemini APIs in any reliably machine-readable format, which is the whole reason the registry has to exist. (Azure, AWS and GCP do publish machine-readable SKU prices for their hosted variants, so this is about the direct APIs.)&lt;/p&gt;

&lt;h3&gt;
  
  
  A 10% gap is treated as normal
&lt;/h3&gt;

&lt;p&gt;The closest thing to a published drift number I found is a troubleshooting band. LiteLLM's cost-discrepancy doc says deltas under roughly 10% between tracked spend and the bill are commonly explained by rounding and boundary effects, and bigger ones usually mean something got miscounted, dropped or categorized differently. "Usually". That's a heuristic in a troubleshooting doc, not a measurement, and it's still the best public number there is.&lt;/p&gt;

&lt;h3&gt;
  
  
  Untracked calls still get billed
&lt;/h3&gt;

&lt;p&gt;Everything above assumes the call went through your tracking in the first place. The one-off script, the teammate's experiment, the key that leaked. Your tracking shows you the calls it saw. The invoice shows everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  What can you use to reconcile LLM costs?
&lt;/h2&gt;

&lt;p&gt;I'm building a product in this space, so discount everything that follows, and check it against the vendors' own pages.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;What it gives you&lt;/th&gt;
&lt;th&gt;The limit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Trackers (Langfuse, Helicone, LiteLLM)&lt;/td&gt;
&lt;td&gt;Traces, evals, per-request cost estimates&lt;/td&gt;
&lt;td&gt;Cost is estimated from tokens, not checked against the bill&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gateways (OpenRouter, Vercel AI Gateway)&lt;/td&gt;
&lt;td&gt;Authoritative cost for routed traffic&lt;/td&gt;
&lt;td&gt;It's the biller's own receipt, and only for traffic through it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spend and cloud cost platforms&lt;/td&gt;
&lt;td&gt;The provider's number, organized and forecasted&lt;/td&gt;
&lt;td&gt;One source, nothing to check it against&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enterprise audit services&lt;/td&gt;
&lt;td&gt;Recovered overcharges, at enterprise scale&lt;/td&gt;
&lt;td&gt;A project, not a product&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DIY with the provider cost APIs&lt;/td&gt;
&lt;td&gt;A real reconciliation, once&lt;/td&gt;
&lt;td&gt;Breaks down at per-key, per-feature granularity, every month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kenda (mine)&lt;/td&gt;
&lt;td&gt;Both numbers and the delta, labeled by evidence&lt;/td&gt;
&lt;td&gt;Early, I'm building it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you're on Langfuse, Helicone or LiteLLM: they're great at what they're actually for, and to their credit, their own docs are where I learned most of what's in this post. Checking the invoice was never their job.&lt;/p&gt;

&lt;p&gt;If your traffic goes through a gateway, the gateway's figure is authoritative for that traffic, because the gateway is the one charging you. That's also the limit: a receipt is not an audit, and calls that don't go through it don't exist to it.&lt;/p&gt;

&lt;p&gt;The spend management and cloud cost platforms mostly read the provider's billing APIs, so they give you the provider's number back, organized. Useful, but you can't check a number against itself. The few products that hold both a client-side measurement and the billing data don't, as far as I can find, show you the difference between the two as a first-class view. If you know one that does, tell me in the comments, I'd like to be wrong about this.&lt;/p&gt;

&lt;p&gt;And the spreadsheet genuinely works at small scale. OpenAI exposes an organization costs endpoint, Anthropic a cost report. Pull both, export your tracked spend, join by hand. If the numbers match within noise, you're done. The pain shows up when you want the answer per key, per feature, per customer, across billing-cycle boundaries instead of calendar months, every month.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is Kenda?
&lt;/h2&gt;

&lt;p&gt;Kenda is an AI spend reconciliation tool: it compares the LLM costs your own code logged against the dollars the provider actually billed, and shows the difference, line by line. On one side, the events your code produced, priced at provider rates. On the other, the spend the provider itself asserts. Both numbers side by side, with the delta, per billing period. That's what I'm building at &lt;a href="https://kenda.app" rel="noopener noreferrer"&gt;kenda.app&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The design decision I care most about: every figure says how far the evidence goes. Reconciled means the provider asserted dollars at that scope. Priced means the provider asserted the tokens and Kenda applied the rates. Allocated means only Kenda's own events exist there. Whatever can't be attributed shows up as its own visible line instead of getting smeared across the rest. If a cost tool never shows you a residual, it's hiding one.&lt;/p&gt;

&lt;p&gt;It's boring infrastructure. Reconciliation always is. I spent six years reconciling money nobody could account for, in healthcare billing and fintech, and this is the same discipline pointed at AI spend.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;How far off is tracked LLM spend from the real invoice?&lt;/strong&gt;&lt;br&gt;
Nobody has published a measured distribution. The closest public reference is LiteLLM's troubleshooting doc, which treats deltas under roughly 10% as commonly explained by rounding and boundary effects. If you've measured your own gap, that number is more valuable than anything in this post: leave it in the comments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I check my OpenAI or Anthropic bill programmatically?&lt;/strong&gt;&lt;br&gt;
OpenAI exposes an organization costs endpoint that breaks spend down by project, API key and line item. Anthropic exposes a cost report at workspace level. Pull those, export what your tracking recorded for the same billing period, and compare. The billing period matters: provider cycles don't always align with calendar months.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why is my OpenAI bill higher than my tracking shows?&lt;/strong&gt;&lt;br&gt;
The usual suspects, in order: calls that never went through your tracking (scripts, experiments, leaked keys), cached tokens counted differently than your tool assumes, and stale pricing data. Whatever is left after those is the residual, and the residual is the part worth investigating, not smearing across the rest.&lt;/p&gt;




&lt;p&gt;If you've ever compared your tracked LLM spend against the actual invoice, I want two data points: how far off it was, and whether you could explain the difference. Rough numbers are fine. "We never checked" is a genuinely useful answer, and I expect it to be the most common one.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>devops</category>
      <category>finops</category>
    </item>
    <item>
      <title>SSE vs WebSocket vs WebTransport: How to Choose in 2026</title>
      <dc:creator>Lara Mateo</dc:creator>
      <pubDate>Thu, 25 Jun 2026 06:40:18 +0000</pubDate>
      <link>https://dev.to/rinava/sse-vs-websocket-vs-webtransport-how-to-choose-in-2026-1lia</link>
      <guid>https://dev.to/rinava/sse-vs-websocket-vs-webtransport-how-to-choose-in-2026-1lia</guid>
      <description>&lt;h2&gt;
  
  
  SSE, WebSocket, or WebTransport? A practical 2026 guide to picking a real-time web protocol, with a Next.js example streaming LLM tokens over Server-Sent Events.
&lt;/h2&gt;

&lt;p&gt;A few weeks ago I shipped &lt;a href="https://pagewise.laramateo.com/" rel="noopener noreferrer"&gt;Pagewise&lt;/a&gt;, a tool that takes any public URL and streams back an AI summary token by token. The streaming is the whole experience. Watching the summary type itself out feels alive in a way that a spinner followed by a wall of text never does.&lt;/p&gt;

&lt;p&gt;To build that, I had to answer a question that sounds simple and isn't: how do you get the server to push data to the browser in real time?&lt;/p&gt;

&lt;p&gt;There are three real answers in 2026: Server-Sent Events (SSE), WebSocket, and WebTransport. They are not interchangeable, and picking the wrong one means either fighting your infrastructure or building machinery you never needed. Here is what each one is for, where it fits, and why I landed on SSE.&lt;/p&gt;

&lt;p&gt;(In my first post on this topic I called WebTransport a "fingers crossed" bet on the future. That changed in March. More on that below.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The short answer:&lt;/strong&gt; Use SSE when only the server needs to push data, such as streaming AI responses, live dashboards, and notifications. Use WebSocket when the client and server both send messages constantly, such as chat, multiplayer, and collaborative editing. Use WebTransport, supported in every major browser since March 2026, when you need unreliable low-latency datagrams or QUIC's resilience on shaky networks, and always pair it with a WebSocket fallback.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with one question: who's doing the talking?
&lt;/h2&gt;

&lt;p&gt;Most real-time decisions come down to direction.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If only the server pushes and the client just listens, you want the simplest thing that streams. That is SSE.&lt;/li&gt;
&lt;li&gt;If both sides send messages constantly, you want a two-way channel. That is WebSocket.&lt;/li&gt;
&lt;li&gt;If you need two-way plus unreliable, low-latency data (or you are on flaky mobile networks), that is where WebTransport comes in.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Worth saying plainly: a smaller share of features truly need bidirectional communication than most of us assume. "Real-time" gets used as a synonym for "WebSocket", but a lot of real-time problems (notifications, dashboards, progress bars, and yes, LLM output) only flow one way. Naming the direction first saves you from over-building.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is SSE (Server-Sent Events)?
&lt;/h2&gt;

&lt;p&gt;Server-Sent Events (SSE) is a one-way channel that lets a server push a stream of text updates to the browser over a single, long-lived HTTP connection. It is the boring-in-a-good-way option, running over plain HTTP. The client opens a normal GET request, the server responds with &lt;code&gt;Content-Type: text/event-stream&lt;/code&gt; and keeps the connection open, pushing chunks as they are ready. No upgrade, no special port. Any proxy, load balancer, or CDN that already understands HTTP understands SSE.&lt;/p&gt;

&lt;p&gt;On the browser, the entire client side is the &lt;code&gt;EventSource&lt;/code&gt; 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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;events&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;/api/summarize&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;events&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;token&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="nf"&gt;appendToSummary&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="nx"&gt;text&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 wire format is just UTF-8 text. Each message is a small block of lines ending in a blank line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;token&lt;/span&gt;
&lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text"&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hello"&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;42&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four fields carry the weight: &lt;code&gt;data&lt;/code&gt; (the payload), &lt;code&gt;event&lt;/code&gt; (the event name), &lt;code&gt;id&lt;/code&gt; (the last-seen event ID), and &lt;code&gt;retry&lt;/code&gt; (the reconnect delay in milliseconds).&lt;/p&gt;

&lt;p&gt;What I like about it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Reconnection is free. If the connection drops, the browser reconnects on its own (after about three seconds by default) and sends a &lt;code&gt;Last-Event-ID&lt;/code&gt; header with the last ID it saw, so the server can resume exactly where it left off. With raw WebSocket, all of that is your job.&lt;/li&gt;
&lt;li&gt;It is about as simple as real-time gets. No handshake, no protocol of your own, no sticky sessions.&lt;/li&gt;
&lt;li&gt;It works with the boring infrastructure you already have.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What to watch out for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Text only. SSE carries UTF-8. You can send binary by base64-encoding it, but that adds roughly 33% to the payload, so it is the wrong tool for binary throughput.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;EventSource&lt;/code&gt; is GET-only and cannot set custom headers, so no &lt;code&gt;Authorization: Bearer&lt;/code&gt;. You work around it with cookies, or you skip &lt;code&gt;EventSource&lt;/code&gt; and read the stream yourself with &lt;code&gt;fetch()&lt;/code&gt; and a &lt;code&gt;ReadableStream&lt;/code&gt; reader (which is what most AI SDKs do, trading the free reconnection for the ability to send headers).&lt;/li&gt;
&lt;li&gt;On HTTP/1.1, browsers cap you at six connections per domain, and every open stream eats one slot, which gets rough across multiple tabs. HTTP/2 multiplexes everything over one connection and the limit disappears. On a modern host you are almost certainly fine.&lt;/li&gt;
&lt;li&gt;The classic deployment bug: a proxy buffering your stream so tokens arrive in one dump instead of one at a time. If you see that, disable buffering (for Nginx, the &lt;code&gt;X-Accel-Buffering: no&lt;/code&gt; header).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqb4kjolq7pr0g90c9msd.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%2Fqb4kjolq7pr0g90c9msd.png" alt="SSE connection lifecycle: a client opens an EventSource connection, the server responds with text/event-stream, Cache-Control no-cache, and keep-alive headers, then streams event and data messages until the connection closes." width="800" height="1412"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What is WebSocket, and how is it different from SSE?
&lt;/h2&gt;

&lt;p&gt;WebSocket is a protocol that holds a single TCP connection open as a full-duplex channel, so the client and server can both send messages at any time. When the client also needs to push constantly, this is the answer. It starts as an HTTP request with an &lt;code&gt;Upgrade&lt;/code&gt; header, the server replies &lt;code&gt;101 Switching Protocols&lt;/code&gt;, and from that point the connection is full-duplex: both sides send whenever they want, with no request and response pairing. It speaks &lt;code&gt;ws://&lt;/code&gt; and &lt;code&gt;wss://&lt;/code&gt; (the TLS version, the one you should use), and it carries binary natively, no base64 tax.&lt;/p&gt;

&lt;p&gt;The cost is everything SSE gives you for free. WebSocket has no built-in reconnection and no resume. You implement reconnection (usually exponential backoff), you track sequence numbers if you need to replay missed messages, and you run your own heartbeats (ping and pong frames) to notice dead connections. All of that is doable, it is just work you are signing up for.&lt;/p&gt;

&lt;p&gt;Reach for WebSocket when the interaction is two-way and high-frequency: chat, multiplayer games, collaborative editing (think live cursors in a shared doc), and trading terminals. If you find yourself adding WebSocket and then only ever sending from the server, that is a sign SSE was the better fit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is WebTransport, and is it ready in 2026?
&lt;/h2&gt;

&lt;p&gt;WebTransport is a browser API for low-latency, two-way communication over HTTP/3 and QUIC, supporting both reliable streams and unreliable datagrams. It is the newest of the three, and the one whose status just changed. Because QUIC sits on UDP, it gives you things the TCP-based protocols cannot:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Both reliable, ordered streams (like WebSocket) and unreliable datagrams (like UDP), so you choose per message. A player position update that is already stale by the time it arrives should be dropped, not retransmitted.&lt;/li&gt;
&lt;li&gt;No head-of-line blocking. On TCP, one lost packet stalls everything queued behind it. QUIC multiplexes independent streams, so a loss on one does not freeze the others.&lt;/li&gt;
&lt;li&gt;Connection migration. Switch from Wi-Fi to cellular mid-session and the connection survives, because QUIC identifies it by a connection ID rather than the IP and port pairing that WebSocket depends on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The 2026 update: WebTransport reached Baseline browser support when Safari 26.4 shipped it on 24 March 2026. Chrome, Edge, Firefox, and Opera had it for a while; Safari was the holdout that kept it out of cross-browser production, and that is now resolved. Apple positioned it plainly as a modern alternative to WebSocket for low-latency use cases like multiplayer and live collaboration.&lt;/p&gt;

&lt;p&gt;It is not a free lunch yet, though:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The specs are still drafts at both the W3C and the IETF, so breaking changes can land, and server libraries sometimes lag the browser.&lt;/li&gt;
&lt;li&gt;It rides UDP on port 443, which plenty of corporate and public networks block. If the handshake fails there, you need a WebSocket fallback. In practice you ship WebTransport with a safety net, not on its own.&lt;/li&gt;
&lt;li&gt;The server tooling is younger than the WebSocket ecosystem.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Track it, prototype with it if you need datagrams or connection migration, and keep WebSocket as the dependable default for now.&lt;/p&gt;

&lt;h2&gt;
  
  
  SSE vs WebSocket vs WebTransport: a comparison table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;SSE&lt;/th&gt;
&lt;th&gt;WebSocket&lt;/th&gt;
&lt;th&gt;WebTransport&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Direction&lt;/td&gt;
&lt;td&gt;Server to client&lt;/td&gt;
&lt;td&gt;Two-way&lt;/td&gt;
&lt;td&gt;Two-way&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transport&lt;/td&gt;
&lt;td&gt;Plain HTTP&lt;/td&gt;
&lt;td&gt;TCP (after upgrade)&lt;/td&gt;
&lt;td&gt;HTTP/3 and QUIC (UDP)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data&lt;/td&gt;
&lt;td&gt;Text (base64 for binary)&lt;/td&gt;
&lt;td&gt;Text and binary&lt;/td&gt;
&lt;td&gt;Binary streams and datagrams&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reconnect&lt;/td&gt;
&lt;td&gt;Automatic, with resume&lt;/td&gt;
&lt;td&gt;Build it yourself&lt;/td&gt;
&lt;td&gt;Build it yourself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Infrastructure&lt;/td&gt;
&lt;td&gt;Simplest&lt;/td&gt;
&lt;td&gt;Needs upgrade-aware proxies&lt;/td&gt;
&lt;td&gt;Needs UDP/QUIC and a fallback&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browser support&lt;/td&gt;
&lt;td&gt;Universal since 2011&lt;/td&gt;
&lt;td&gt;Universal&lt;/td&gt;
&lt;td&gt;Baseline since March 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;LLM streaming, dashboards, notifications&lt;/td&gt;
&lt;td&gt;Chat, multiplayer, collaboration&lt;/td&gt;
&lt;td&gt;Low-latency media, gaming, telemetry&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Rules of thumb I use:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Only the server pushes, the client listens: SSE.&lt;/li&gt;
&lt;li&gt;Both sides push, a lot: WebSocket.&lt;/li&gt;
&lt;li&gt;You need unreliable datagrams, stream multiplexing without head-of-line blocking, or survival across a network change: WebTransport, with a WebSocket fallback.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why I chose SSE for Pagewise (streaming LLM tokens)
&lt;/h2&gt;

&lt;p&gt;Run Pagewise through that filter and the answer is immediate. The server streams a summary; the client just renders it. The data is text (markdown tokens). It needs to survive a flaky connection without losing its place. And it runs on serverless.&lt;/p&gt;

&lt;p&gt;That is the SSE column, top to bottom.&lt;/p&gt;

&lt;p&gt;There is a second reason that sealed it: the entire LLM ecosystem already streams this way. OpenAI and Anthropic both deliver tokens as &lt;code&gt;text/event-stream&lt;/code&gt; (OpenAI sends &lt;code&gt;data:&lt;/code&gt; lines ending in a &lt;code&gt;data: [DONE]&lt;/code&gt; marker; Anthropic sends named events like &lt;code&gt;content_block_delta&lt;/code&gt;). The Vercel AI SDK standardized on SSE for its streaming protocol as of version 5. Choosing SSE meant swimming with the current instead of against it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to implement SSE in a Next.js route handler
&lt;/h2&gt;

&lt;p&gt;Here is the shape of it in Pagewise, a Next.js route handler that streams Claude's output as typed events (trimmed here to the streaming essentials):&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;// app/api/summarize/route.ts&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;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;encoder&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;TextEncoder&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;stream&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;ReadableStream&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;send&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;event&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="na"&gt;data&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="o"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="nx"&gt;encoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`event: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\ndata: &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;stringify&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="s2"&gt;\n\n`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;

      &lt;span class="c1"&gt;// Kick the work off in the background so chunks flush as they arrive,&lt;/span&gt;
      &lt;span class="c1"&gt;// instead of buffering the whole response until the handler returns.&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &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="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;metadata&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;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;faviconUrl&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

          &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;await &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;token&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;streamFromClaude&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="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;token&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
          &lt;span class="p"&gt;}&lt;/span&gt;

          &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;done&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;sessionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;wordCount&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;LLM_FAILURE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Generation failed&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="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&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="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;headers&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;Content-Type&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;text/event-stream&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;Cache-Control&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;no-cache&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;Connection&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;keep-alive&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things in there are the difference between "it streams" and "it dumps everything at the end":&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It is a route handler, not a server action. Server actions are built for mutations and cannot stream an HTTP response incrementally, so streaming has to live in a route.&lt;/li&gt;
&lt;li&gt;The producing loop runs in the background inside &lt;code&gt;start()&lt;/code&gt;. If you &lt;code&gt;await&lt;/code&gt; the whole generation before returning the &lt;code&gt;Response&lt;/code&gt;, the framework buffers it, and your user gets the spinner-then-wall-of-text experience you were trying to avoid.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The typed events (&lt;code&gt;metadata&lt;/code&gt;, &lt;code&gt;token&lt;/code&gt;, &lt;code&gt;done&lt;/code&gt;, and &lt;code&gt;error&lt;/code&gt;) map cleanly onto the client, where each one gets its own handler. And because this is serverless, the one number you cannot ignore is the function's maximum duration. A long generation can outlast it, so set &lt;code&gt;maxDuration&lt;/code&gt; deliberately, and if your responses can run very long, reach for resumable streams rather than hoping the function stays alive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is SSE better than WebSocket?
&lt;/h3&gt;

&lt;p&gt;Neither is better in the abstract; they solve different problems. SSE is the simpler choice when only the server pushes data: notifications, dashboards, and LLM token streaming. WebSocket is the right tool when the client and server both send messages continuously, like chat or multiplayer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does SSE work with HTTP/2?
&lt;/h3&gt;

&lt;p&gt;Yes, and it is better there. On HTTP/1.1 a browser allows only six connections per domain, and each open SSE stream uses one. HTTP/2 multiplexes many streams over a single connection, so that limit effectively disappears. Most modern hosts serve HTTP/2 by default.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can SSE send binary data?
&lt;/h3&gt;

&lt;p&gt;Not directly. An SSE stream is UTF-8 text. You can base64-encode binary to send it, but that inflates the payload by about a third, so for real binary throughput WebSocket or WebTransport is the better fit.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does the Vercel AI SDK use for streaming?
&lt;/h3&gt;

&lt;p&gt;Server-Sent Events. As of AI SDK 5, Vercel standardized its server-to-client streaming on SSE, which is also how the OpenAI and Anthropic APIs deliver tokens. If you are streaming LLM output, you are almost certainly using SSE under the hood.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is WebTransport production-ready in 2026?
&lt;/h3&gt;

&lt;p&gt;It is now usable across every major browser: Safari shipped it in version 26.4 on 24 March 2026, joining Chrome, Edge, Firefox, and Opera. The specifications are still drafts, and some networks block its UDP traffic, so ship it with a WebSocket fallback.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does my SSE stream arrive all at once instead of streaming?
&lt;/h3&gt;

&lt;p&gt;Usually a proxy or framework is buffering the response. Disable buffering at the proxy (for Nginx, set &lt;code&gt;X-Accel-Buffering: no&lt;/code&gt;), and make sure your server flushes each event instead of awaiting the whole response before it returns.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;Real-time on the web is not one decision, it is three, and the first question does most of the work: who needs to push data, and in which direction? Answer that clearly and the protocol usually picks itself.&lt;/p&gt;

&lt;p&gt;For anything that looks like streaming an AI response, a live feed, a dashboard, or a progress indicator, SSE is very likely the right call, and it is a lot less work than its reputation suggests. WebSocket earns its keep the moment the client becomes a real sender. And WebTransport, as of this year, is finally a production option rather than a someday one.&lt;/p&gt;

&lt;p&gt;I'm curious: has anyone here moved off WebSocket to SSE for an AI feature, or are you already running WebTransport in production? Tell me in the comments.&lt;/p&gt;




&lt;h3&gt;
  
  
  Further reading
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events" rel="noopener noreferrer"&gt;MDN Web Docs: Using server-sent events&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/EventSource" rel="noopener noreferrer"&gt;MDN Web Docs: the EventSource API&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://html.spec.whatwg.org/multipage/server-sent-events.html" rel="noopener noreferrer"&gt;WHATWG HTML Living Standard: Server-sent events&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://webkit.org/blog/17862/webkit-features-for-safari-26-4/" rel="noopener noreferrer"&gt;WebKit: Safari 26.4 features (WebTransport)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://vercel.com/blog/ai-sdk-5" rel="noopener noreferrer"&gt;Vercel: the AI SDK 5 announcement&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.openai.com/api/docs/guides/streaming-responses" rel="noopener noreferrer"&gt;OpenAI: streaming API responses&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://platform.claude.com/docs/en/build-with-claude/streaming" rel="noopener noreferrer"&gt;Anthropic: streaming messages&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>nextjs</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
