{"id":201,"date":"2026-05-08T13:01:27","date_gmt":"2026-05-08T13:01:27","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/websockets-vs-server-sent-events-when-i-reach-for-each\/"},"modified":"2026-05-08T13:01:27","modified_gmt":"2026-05-08T13:01:27","slug":"websockets-vs-server-sent-events-when-i-reach-for-each","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/websockets-vs-server-sent-events-when-i-reach-for-each\/","title":{"rendered":"WebSockets vs Server-Sent Events: When I Reach for Each in 2026"},"content":{"rendered":"<p>I built my first &ldquo;real-time&rdquo; feature in 2018 by reaching for <code>socket.io<\/code> because that&rsquo;s what every tutorial used. The feature was a server-pushed notification banner. Pure server-to-client, one-way, maybe one message every five minutes. I&rsquo;d just spent two days configuring sticky sessions on a load balancer for a feature that could&rsquo;ve been an HTTP endpoint with a <code>text\/event-stream<\/code> header and zero new infrastructure. I&rsquo;m still a little embarrassed about it.<\/p>\n<p>Since then I&rsquo;ve shipped both protocols across enough Next.js dashboards, Laravel admin panels, and Go microservices to have a strong opinion. Short version for the impatient: if your data flows in one direction (server to client) and you can tolerate plain HTTP, use Server-Sent Events. If you genuinely need bidirectional, low-latency messaging, use WebSockets. The interesting part is everything in the middle.<\/p>\n<h2 id=\"what-sse-actually-is-and-what-it-isnt\">What SSE actually is, and what it isn&rsquo;t<\/h2>\n<p>SSE is a tiny protocol on top of plain HTTP. The server keeps a connection open and writes lines like <code>data: hello\\n\\n<\/code> whenever it wants to push something. The browser&rsquo;s <code>EventSource<\/code> API listens. You can read the entire spec in the <a href=\"https:\/\/html.spec.whatwg.org\/multipage\/server-sent-events.html\" rel=\"nofollow noopener\" target=\"_blank\">WHATWG HTML living standard<\/a> and it&rsquo;s about a page long.<\/p>\n<p>The good parts: it works through every proxy that handles HTTP, it auto-reconnects when the network blips, it carries an event ID so the server can resume from where you left off, and it costs zero new infrastructure. You can serve it from the same Express, FastAPI, or Laravel route that serves the rest of your app. No upgrade handshake. No special load balancer rules. No &ldquo;make sure the WebSocket port isn&rsquo;t filtered.&rdquo;<\/p>\n<p>The bad parts: it&rsquo;s text-only. The browser limits you to about six concurrent SSE connections per origin over HTTP\/1.1. And the client cannot send messages back on the same connection. If your client needs to talk to the server, it makes a separate HTTP request. Not a problem for most use cases. A real problem for a chat app.<\/p>\n<h2 id=\"what-websockets-give-you-that-sse-doesnt\">What WebSockets give you that SSE doesn&rsquo;t<\/h2>\n<p>A WebSocket connection is bidirectional and binary-capable. After an HTTP upgrade handshake, both ends can write whenever they want. The framing is described in <a href=\"https:\/\/www.rfc-editor.org\/rfc\/rfc6455\" rel=\"nofollow noopener\" target=\"_blank\">RFC 6455<\/a> and you should at least skim it once.<\/p>\n<p>The actual reasons I reach for WebSockets:<\/p>\n<ol>\n<li>The client sends messages too. Multiplayer games, collaborative editors, chat. Anything where typing on one machine should immediately affect another.<\/li>\n<li>I need binary frames. Audio, video chunks, custom binary protocols. SSE is text-only, and base64-encoding binary over SSE is a sad way to live.<\/li>\n<li>I&rsquo;m already on a stack with great WebSocket primitives. Phoenix Channels and Laravel Reverb come to mind. Fighting the framework to use SSE instead is rarely worth it.<\/li>\n<\/ol>\n<p>WebSockets have hidden costs. You need a process that holds connections open, which means a real plan for restarts and deploys. You need to think about heartbeats because middleboxes love to kill idle TCP connections. And on serverless platforms, <a href=\"https:\/\/vercel.com\/docs\/functions\/streaming\/quickstart\" rel=\"nofollow noopener\" target=\"_blank\">SSE routes through standard streaming responses<\/a> but WebSockets need a different runtime entirely.<\/p>\n<h2 id=\"my-actual-decision-tree\">My actual decision tree<\/h2>\n<p>This is the thing I run through in my head, in this order.<\/p>\n<p>Does the client need to send messages on the same connection? If yes, WebSockets. Stop here.<\/p>\n<p>Is the data binary? If yes, WebSockets.<\/p>\n<p>Am I building on serverless functions or edge runtimes? If yes, almost always SSE. The model fits the platform.<\/p>\n<p>Is the average message rate under one per second per client? SSE is more than enough.<\/p>\n<p>Do I need 10,000+ concurrent connections from a single box? Now I have to actually benchmark, and the answer often surprises me.<\/p>\n<p>Most features land on SSE. I think a lot of teams default to WebSockets out of habit, the way I did in 2018.<\/p>\n<h2 id=\"the-reconnect-problem-where-sse-quietly-wins\">The reconnect problem, where SSE quietly wins<\/h2>\n<p>Real networks are not the diagram in your architecture deck. Phones switch from wifi to LTE. Laptops sleep. NAT tables expire. Connections die without telling anyone.<\/p>\n<p>SSE reconnects automatically. The browser stores the last event ID it received and sends it back as <code>Last-Event-ID<\/code> on the next request. Your server reads that header and resumes from the right point. You write basically zero client code for this. The <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/EventSource\" rel=\"nofollow noopener\" target=\"_blank\">MDN reference for EventSource<\/a> covers exactly how the retry interval and last event ID work in practice.<\/p>\n<p>WebSocket reconnect is your problem. You write the timer. You write the backoff. You write the server-side state recovery. You explain to your teammate why messages got delivered twice during a flap. There are libraries, but they&rsquo;re papering over a thing the browser does for free with SSE.<\/p>\n<p>I&rsquo;ve seen &ldquo;WebSocket flakiness&rdquo; reported as a customer-facing bug enough times that I now treat reconnect logic as part of the cost of choosing WebSockets, not a footnote.<\/p>\n<h2 id=\"what-i-got-wrong-about-scaling\">What I got wrong about scaling<\/h2>\n<p>For years I assumed SSE was &ldquo;fine for small things, but won&rsquo;t scale.&rdquo; That was lazy thinking on my part.<\/p>\n<p>Per-connection memory for an idle SSE stream on a tuned Go server is in the low kilobytes. The same is true for WebSockets. The bottleneck for both is usually file descriptors and the kernel&rsquo;s TCP buffers, not the protocol overhead.<\/p>\n<p>The HTTP\/1.1 six-connection-per-origin limit is real, but if you&rsquo;re on HTTP\/2 or HTTP\/3 (which you probably are if you&rsquo;re behind any modern CDN), it goes away. Browsers multiplex streams over one TCP connection. The old &ldquo;SSE doesn&rsquo;t scale&rdquo; advice is mostly leftover from 2015.<\/p>\n<p>That said, WebSockets have a genuine edge for write-heavy workloads where every client also produces messages. A chat app with a thousand active typists is doing real work over each connection. SSE plus a separate POST endpoint per message can work, but the connection-per-write pattern starts to feel silly past a certain rate.<\/p>\n<h2 id=\"a-real-example-streaming-llm-responses\">A real example: streaming LLM responses<\/h2>\n<p>The clearest case I&rsquo;ve shipped is streaming text from an LLM to a browser. I covered the rest of the pipeline in <a href=\"https:\/\/abrarqasim.com\/blog\/vercel-ai-sdk-v5-moving-off-raw-openai\/\" rel=\"noopener\">my post on moving off raw OpenAI calls to the Vercel AI SDK<\/a>, but the protocol choice is worth calling out separately.<\/p>\n<p>The pattern is purely server to client. The model emits tokens, the server forwards them, the user reads them. Nothing flows the other way until the user types another prompt, which is a fresh request anyway. SSE fits this perfectly, and that&rsquo;s why every major AI SDK ships SSE support out of the box.<\/p>\n<p>Here&rsquo;s the entire server side in a Next.js route handler:<\/p>\n<pre><code class=\"language-ts\">export const runtime = 'edge'\n\nexport async function POST(req: Request) {\n  const { prompt } = await req.json()\n  const stream = await callModel(prompt)\n\n  return new Response(stream, {\n    headers: {\n      'Content-Type': 'text\/event-stream',\n      'Cache-Control': 'no-cache, no-transform',\n      Connection: 'keep-alive',\n    },\n  })\n}\n<\/code><\/pre>\n<p>And the client:<\/p>\n<pre><code class=\"language-ts\">const es = new EventSource('\/api\/chat?prompt=hello')\nes.onmessage = (e) =&gt; append(e.data)\nes.onerror = () =&gt; es.close()\n<\/code><\/pre>\n<p>That&rsquo;s the whole thing. No reconnect plumbing, no heartbeat loop, no upgrade handshake. If I tried to do this with WebSockets I&rsquo;d add a hundred lines of client and server code for no benefit.<\/p>\n<p>For comparison, the same feature over WebSockets looks like this on the client:<\/p>\n<pre><code class=\"language-ts\">let ws: WebSocket | null = null\nlet retry = 1000\n\nfunction connect() {\n  ws = new WebSocket('wss:\/\/example.com\/chat')\n  ws.onmessage = (e) =&gt; append(e.data)\n  ws.onclose = () =&gt; {\n    setTimeout(connect, retry)\n    retry = Math.min(retry * 2, 30000)\n  }\n  ws.onopen = () =&gt; { retry = 1000 }\n}\n\nconnect()\nfunction send(prompt: string) {\n  ws?.send(JSON.stringify({ type: 'prompt', prompt }))\n}\n<\/code><\/pre>\n<p>Reasonable code, but I&rsquo;m now responsible for the reconnect, the backoff, and a message format. None of that earned its keep for a one-way stream.<\/p>\n<h2 id=\"when-i-do-reach-for-websockets\">When I do reach for WebSockets<\/h2>\n<p>Three projects in the last year, all genuine cases.<\/p>\n<p>A collaborative whiteboard for a small team I worked with. Pointer positions, drawing strokes, presence updates, all bidirectional, all twenty times per second. WebSockets, with a lightweight CRDT on top.<\/p>\n<p>An internal trading dashboard. The team executes trades themselves through their broker; my dashboard just visualizes positions. But the live position view used WebSockets because the same socket pushed user actions back to the server for audit logging. Bidirectional with strict ordering.<\/p>\n<p>A Phoenix LiveView app where the framework already does WebSockets for me. Going against the grain there is a great way to lose a weekend.<\/p>\n<p>If you&rsquo;re curious about the kind of stack decisions I make on real projects, I keep a running list of <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">the work I&rsquo;m proud of<\/a>. The pattern is usually &ldquo;use the boring thing unless the boring thing fails a specific requirement.&rdquo;<\/p>\n<h2 id=\"try-this-in-the-next-30-minutes\">Try this in the next 30 minutes<\/h2>\n<p>Pick one feature in your app that currently uses long-polling, manual <code>setInterval<\/code> fetches, or a WebSocket that only ever sends server-to-client. Rewrite it as SSE. Time yourself. I&rsquo;d bet most readers can do it in under an hour, and the diff will delete more code than it adds. If it ends up worse, I want to hear about it. That&rsquo;s how I find the limits of my own opinion.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>I&#8217;ve built real-time features both ways for years. Here&#8217;s how I actually pick between WebSockets and Server-Sent Events without overthinking it.<\/p>\n","protected":false},"author":2,"featured_media":200,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"I've built real-time features both ways for years. Here's how I actually pick between WebSockets and Server-Sent Events without overthinking it.","rank_math_focus_keyword":"server sent events vs websockets","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[35],"tags":[44,234,232,233,235,39,231],"class_list":["post-201","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-web-development","tag-javascript","tag-real-time","tag-server-sent-events","tag-sse","tag-streaming","tag-web-development","tag-websockets"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/201","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=201"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/201\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/200"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=201"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=201"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=201"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}