<?xml version="1.0" encoding="utf-8"?><?xml-stylesheet type="text/xsl" href="atom.xsl"?>
<feed xmlns="http://www.w3.org/2005/Atom">
    <id>https://kartikrocks.github.io/wshub/blog</id>
    <title>wshub Blog</title>
    <updated>2026-10-16T00:00:00.000Z</updated>
    <generator>https://github.com/jpmonette/feed</generator>
    <link rel="alternate" href="https://kartikrocks.github.io/wshub/blog"/>
    <subtitle>wshub Blog</subtitle>
    <icon>https://kartikrocks.github.io/wshub/img/favicon.ico</icon>
    <entry>
        <title type="html"><![CDATA[Why I Built Another WebSocket Library for Go (and Why It Might Be the Last One You Need)]]></title>
        <id>https://kartikrocks.github.io/wshub/blog/another-websocket-library-for-go</id>
        <link href="https://kartikrocks.github.io/wshub/blog/another-websocket-library-for-go"/>
        <updated>2026-10-16T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Go already has several good WebSocket libraries. None of them solve the problem that actually eats a week of engineering time — so here's an honest survey of the landscape, and where the gap actually is.]]></summary>
        <content type="html"><![CDATA[<p>"Another WebSocket library" is a fair thing to be skeptical of. Go already
has several good ones, some of them excellent at exactly what they set out to
do. So before explaining why <a href="https://github.com/KARTIKrocks/wshub" target="_blank" rel="noopener noreferrer" class="">wshub</a>
exists, the honest version of this post has to start somewhere else: what's
actually already out there, and what each one leaves for you to build
yourself.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-landscape-as-it-actually-is">The landscape, as it actually is<a href="https://kartikrocks.github.io/wshub/blog/another-websocket-library-for-go#the-landscape-as-it-actually-is" class="hash-link" aria-label="Direct link to The landscape, as it actually is" title="Direct link to The landscape, as it actually is" translate="no">​</a></h2>
<p><strong><a href="https://github.com/gorilla/websocket" target="_blank" rel="noopener noreferrer" class=""><code>gorilla/websocket</code></a></strong> is the
long-standing default, and it's what wshub is built on top of. It gives you
one thing well: a <code>Conn</code> with <code>ReadMessage</code>/<code>WriteMessage</code>, ping/pong,
close-frame handling, and <code>permessage-deflate</code> support — a correct
implementation of RFC 6455, nothing more. Everything past "I have a frame" —
registries, rooms, fanout, backpressure — is yours to write.</p>
<p><strong><a href="https://github.com/coder/websocket" target="_blank" rel="noopener noreferrer" class=""><code>coder/websocket</code></a></strong> (the maintained
continuation of <code>nhooyr.io/websocket</code>) is the modern take on the same layer:
zero dependencies, first-class <code>context.Context</code> support, a smaller surface
area than gorilla's. It's a genuinely nicer API to hold a single connection
with. It makes the same choice gorilla does, though — transport only. No
rooms, no broadcast primitive, nothing about what happens when you have ten
thousand of these open at once.</p>
<p><strong><a href="https://github.com/gobwas/ws" target="_blank" rel="noopener noreferrer" class=""><code>gobwas/ws</code></a></strong> goes a level lower than
either: raw frame headers, your own buffer reuse, your own masking. It exists
for people who want to hand-roll the protocol for performance reasons gorilla
and coder's abstractions get in the way of. Correct and fast, and further
from "batteries included" than any library on this list.</p>
<p><strong><a href="https://github.com/olahol/melody" target="_blank" rel="noopener noreferrer" class=""><code>melody</code></a></strong> is the closest thing that
already exists to what wshub is going for — a thin convenience layer over
gorilla that adds broadcast-to-all, broadcast-to-filtered, per-session
key/value storage, and automatic ping/pong timeout handling. If what you need
is "send this to everyone, and don't leak dead connections," melody solves
it in a dozen lines and you should probably use it. What it doesn't have:
rooms as a first-class concept, a backpressure policy when a slow client's
buffer fills, or any story for running more than one node.</p>
<p><strong><a href="https://github.com/centrifugal/centrifuge" target="_blank" rel="noopener noreferrer" class=""><code>centrifuge</code></a></strong> (and its
companion server, <strong>Centrifugo</strong>) sits at the other end entirely. It's a
real-time messaging platform — channels, presence, history/recovery on
reconnect, JWT-based auth — either embedded as a library or run as its own
clustered server that your services talk to over a client SDK. It's the
right answer if you're building the realtime equivalent of a message bus and
want most of the plumbing decided for you. It's also a bigger adoption
decision than a single WebSocket endpoint usually calls for: a new protocol
surface between your services and your clients, and in the server form, a
whole additional system to run and operate.</p>
<p>None of this is a knock on any of them. They're each solving the problem
they set out to solve. The gap is what's left standing between "a raw
transport library" and "a hosted real-time platform" — an <strong>embeddable hub
layer</strong>: something you <code>go get</code> into your own service, not a server you
stand up next to it, that gives you rooms, backpressure, graceful shutdown,
and multi-node scaling as library code you control.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-that-layer-actually-has-to-do">What that layer actually has to do<a href="https://kartikrocks.github.io/wshub/blog/another-websocket-library-for-go#what-that-layer-actually-has-to-do" class="hash-link" aria-label="Direct link to What that layer actually has to do" title="Direct link to What that layer actually has to do" translate="no">​</a></h2>
<p>This isn't abstract. Across the rest of this series, the specific problems
that show up the moment a <code>gorilla/websocket</code> demo meets real traffic — and
that neither a transport library nor a full platform server quite hands you
in the shape a single Go service wants — were:</p>
<ul>
<li class=""><strong>A registry and rooms.</strong> Someone has to track which <code>*Client</code> values are
live and which room each one belongs to, safely under concurrent
connect/disconnect. <a class="" href="https://kartikrocks.github.io/wshub/blog/production-grade-websocket-server-in-go">Article 1</a>
walks through why a plain map behind a mutex isn't enough once broadcast
and unregister can run at the same time.</li>
<li class=""><strong>Backpressure with an actual policy, not a crash.</strong> One slow client
shouldn't be able to block a broadcast to everyone else, and an unbounded
queue for that client is just a slower way to run out of memory. wshub's
answer — bounded per-client channels plus a configurable drop policy
(<code>DropOldest</code> or <code>DropNewest</code>) with a notification hook — is the
subject of <a class="" href="https://kartikrocks.github.io/wshub/blog/building-backpressure-into-a-go-websocket-server">article 3</a>.</li>
<li class=""><strong>A graceful shutdown that actually waits.</strong> "Close every connection" is
one line. Draining in-flight sends, handling a client that's open but
silent, and doing it within a deadline instead of hanging a rolling
deploy needs a small state machine — covered in
<a class="" href="https://kartikrocks.github.io/wshub/blog/graceful-websocket-shutdown-in-go">article 5</a>.</li>
<li class=""><strong>More than one node, correctly.</strong> The moment you run two replicas behind
a load balancer, "broadcast to everyone" has to cross a process boundary
without double-delivering to clients on the node that originated the
message. <a class="" href="https://kartikrocks.github.io/wshub/blog/scaling-websockets-across-multiple-go-servers-with-redis">Article 4</a>
covers the adapter pattern wshub uses for this (Redis and NATS, pluggable,
with node-ID dedup built in) — the part that a single-process library like
melody has no story for at all, and that Centrifugo solves by giving you a
clustered server with its own Redis-backed engine, rather than an adapter
you plug into a service you already run.</li>
<li class=""><strong>Honest numbers for the question everyone asks first.</strong>
<a class="" href="https://kartikrocks.github.io/wshub/blog/handling-10000-websocket-connections-in-go">Article 2</a> measures
what 10,000 open connections and a sustained broadcast actually cost, in
real memory and real latency, instead of leaving you to extrapolate from a
microbenchmark.</li>
</ul>
<p>None of these are hard problems individually. They're the kind of thing a
competent team writes correctly on the third try, after shipping the first
two versions to production and finding out what they missed. wshub's
pitch is narrow: do that work once, benchmark it, and keep it zero-allocation
on the hot path (<code>SendToClient</code> is 105 ns/op with zero allocations at 100K
registered clients — reproducible with <code>go test -bench=. -benchmem ./...</code>),
so the team doesn't have to re-derive it.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="is-it-the-last-one-you-need">Is it "the last one you need"?<a href="https://kartikrocks.github.io/wshub/blog/another-websocket-library-for-go#is-it-the-last-one-you-need" class="hash-link" aria-label="Direct link to Is it &quot;the last one you need&quot;?" title="Direct link to Is it &quot;the last one you need&quot;?" translate="no">​</a></h2>
<p>Honestly — depends on what you're building. If you want a real-time
platform with presence, message history, and reconnect recovery as a
managed concern, and you're fine adopting a server and a client SDK to get
it, centrifuge/Centrifugo is a more complete answer than wshub is trying to
be. If all you need is "broadcast this to everyone on one node," melody is
lighter weight and you don't need wshub's configuration surface at all.</p>
<p>Where wshub earns the "last one" framing is the specific, common middle
case: a single Go service that owns its own WebSocket endpoint, needs rooms
and backpressure and graceful shutdown to be correct from day one, and will
eventually need more than one node — without wanting to stand up a separate
real-time server to get there. That's the gap none of the libraries above
fill as a library, and it's the one wshub was built to close.</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">go get github.com/KARTIKrocks/wshub</span><br></div></code></pre></div></div>
<p>The <a href="https://github.com/KARTIKrocks/wshub/tree/main/examples/chat" target="_blank" rel="noopener noreferrer" class="">room-based chat example</a>
is the fastest way to see rooms and broadcasting working end to end; the
full capability list is in the
<a href="https://github.com/KARTIKrocks/wshub#why-wshub" target="_blank" rel="noopener noreferrer" class="">README</a>, and the rest of
this series goes deep on each piece above.</p>]]></content>
        <author>
            <name>Kartik</name>
            <uri>https://github.com/KARTIKrocks</uri>
        </author>
        <category label="go" term="go"/>
        <category label="websocket" term="websocket"/>
        <category label="opensource" term="opensource"/>
        <category label="backend" term="backend"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Building Backpressure into a Go WebSocket Server]]></title>
        <id>https://kartikrocks.github.io/wshub/blog/building-backpressure-into-a-go-websocket-server</id>
        <link href="https://kartikrocks.github.io/wshub/blog/building-backpressure-into-a-go-websocket-server"/>
        <updated>2026-09-11T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[A bounded channel and a non-blocking send get you 80% of the way to correct backpressure. The other 20% is two specific races that only show up under concurrent load — and they're worth understanding before you hit them in production.]]></summary>
        <content type="html"><![CDATA[<p>Every WebSocket server eventually gets a slow client — bad wifi, a suspended
mobile app, a browser tab that stopped running its event loop. The server
keeps producing messages for it regardless. What happens to those messages
is a design decision, and "block until the client catches up" is the wrong
one: block the wrong goroutine and you've turned one slow client into an
outage for every other connection on the process.</p>
<!-- -->
<p>This is article three in a series on production-grade WebSocket servers in
Go — <a class="" href="https://kartikrocks.github.io/wshub/blog/production-grade-websocket-server-in-go">article one</a> listed the
eight problems production traffic surfaces, <a class="" href="https://kartikrocks.github.io/wshub/blog/handling-10000-websocket-connections-in-go">article two</a>
went deep on connection-scaling arithmetic. This one is about the mechanism
that's easy to get <em>approximately</em> right and surprisingly easy to get
<em>exactly</em> wrong: dropping messages under load without dropping correctness
along with them. The code below is from <a href="https://github.com/KARTIKrocks/wshub" target="_blank" rel="noopener noreferrer" class="">wshub</a>'s
actual send path, not a simplified sketch — the races it guards against are
the reason the "obvious" implementation isn't the one that shipped.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-80-version-and-why-its-not-enough">The 80% version, and why it's not enough<a href="https://kartikrocks.github.io/wshub/blog/building-backpressure-into-a-go-websocket-server#the-80-version-and-why-its-not-enough" class="hash-link" aria-label="Direct link to The 80% version, and why it's not enough" title="Direct link to The 80% version, and why it's not enough" translate="no">​</a></h2>
<p>The standard advice for a slow-consumer problem is: don't write to the
socket directly, write to a bounded channel, and make the write
non-blocking.</p>
<div class="language-go codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-go codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">select</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">case</span><span class="token plain"> client</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">send </span><span class="token operator" style="color:#393A34">&lt;-</span><span class="token plain"> data</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token comment" style="color:#999988;font-style:italic">// enqueued</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">default</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token comment" style="color:#999988;font-style:italic">// buffer full — drop it</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<p>This is correct as far as it goes, and it's most of what you need. A
<code>writePump</code> goroutine drains <code>client.send</code> and owns the actual socket
writes, so a full buffer never blocks the caller — broadcast, targeted send,
whatever code path got you here returns immediately either way. This is why
<code>SendToClient</code> costs ~105 ns with zero allocations at 100K clients in
wshub's benchmarks: the send path never waits on I/O, only on a channel
operation that's guaranteed not to block.</p>
<p>But two things are still unhandled, and both of them only show up under
concurrency — which is exactly why they're easy to ship without noticing.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="race-1-the-client-can-close-between-your-check-and-your-send">Race 1: the client can close between your check and your send<a href="https://kartikrocks.github.io/wshub/blog/building-backpressure-into-a-go-websocket-server#race-1-the-client-can-close-between-your-check-and-your-send" class="hash-link" aria-label="Direct link to Race 1: the client can close between your check and your send" title="Direct link to Race 1: the client can close between your check and your send" translate="no">​</a></h2>
<p>A <code>Client</code> has a <code>closed</code> flag, set when the connection tears down. The
obvious guard is to check it before sending:</p>
<div class="language-go codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-go codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> client</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">closed </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">false</span><span class="token plain"> </span><span class="token comment" style="color:#999988;font-style:italic">// don't bother sending</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">client</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">send </span><span class="token operator" style="color:#393A34">&lt;-</span><span class="token plain"> data</span><br></div></code></pre></div></div>
<p>This looks safe and isn't. Nothing prevents the client from disconnecting —
and its <code>writePump</code> from closing <code>client.send</code> — in the window between the
check and the send. That's a plain time-of-check-to-time-of-use race, and
when it loses, the result isn't a dropped message: it's a panic. Sending on
a closed channel is one of the handful of things Go will not let you
recover from gracefully by checking state first, because there's no atomic
"check and send" primitive for channels the way there is for, say,
<code>sync/atomic.CompareAndSwap</code>.</p>
<p>wshub's actual send path still does the <code>closed</code> check — as a fast-path
optimization, to skip the panic/recover machinery for the common case where
a client is already known to be gone — but it doesn't <em>trust</em> that check.
Every send is wrapped in a <code>recover</code>:</p>
<div class="language-go codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-go codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">defer</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">func</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> r </span><span class="token operator" style="color:#393A34">:=</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">recover</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> r </span><span class="token operator" style="color:#393A34">!=</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">nil</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">isChanSendPanic</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">r</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            ok </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">false</span><span class="token plain"> </span><span class="token comment" style="color:#999988;font-style:italic">// client.send was closed — the client is disconnecting</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token function" style="color:#d73a49">panic</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">r</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token comment" style="color:#999988;font-style:italic">// re-panic for anything else</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<p>The <code>isChanSendPanic</code> check matters more than it looks. Go doesn't give you
a typed error for "send on closed channel" — it's a runtime panic carrying a
plain string, and that string is the only signal available:</p>
<div class="language-go codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-go codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">func</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">isChanSendPanic</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">r any</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token builtin">bool</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token comment" style="color:#999988;font-style:italic">// ...</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> strings</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">Contains</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">msg</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"send on closed channel"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<p>The re-panic on anything else is the important part of this design, not an
afterthought. A <code>recover</code> that swallows <em>every</em> panic turns real bugs — a
nil pointer dereference two calls deep in a message handler, an index out
of range in application code — into silent, undebuggable message drops.
Matching the specific panic string and re-panicking on everything else means
this <code>recover</code> catches exactly the one race it exists to catch, and gets out
of the way of every other failure.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="race-2-evicting-under-dropoldest-isnt-one-operation-either">Race 2: evicting under <code>DropOldest</code> isn't one operation either<a href="https://kartikrocks.github.io/wshub/blog/building-backpressure-into-a-go-websocket-server#race-2-evicting-under-dropoldest-isnt-one-operation-either" class="hash-link" aria-label="Direct link to race-2-evicting-under-dropoldest-isnt-one-operation-either" title="Direct link to race-2-evicting-under-dropoldest-isnt-one-operation-either" translate="no">​</a></h2>
<p><code>DropNewest</code> (discard the message that didn't fit) is race-free almost by
definition — there's nothing to coordinate, you just don't enqueue. <code>DropOldest</code>
(evict the head of the queue to make room for the new message, because only
the latest state matters) is a different story: it's two channel operations
— a receive to evict, a send to enqueue — and if two goroutines hit this
path for the same client at once, both can decide the buffer is full,
both start evicting, and you can end up dropping two messages to make room
for one.</p>
<p>wshub serializes this specific sequence with a per-client mutex, and only
for this path — the fast, common non-blocking send above never touches it:</p>
<div class="language-go codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-go codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">func</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">h </span><span class="token operator" style="color:#393A34">*</span><span class="token plain">Hub</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">trySendDropOldest</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">client </span><span class="token operator" style="color:#393A34">*</span><span class="token plain">Client</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> item sendItem</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token builtin">bool</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    client</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">sendMu</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">Lock</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">defer</span><span class="token plain"> client</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">sendMu</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">Unlock</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token comment" style="color:#999988;font-style:italic">// Re-check: the buffer may have drained while we waited for the lock —</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token comment" style="color:#999988;font-style:italic">// writePump could have pulled a message off it in the meantime.</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">select</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">case</span><span class="token plain"> client</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">send </span><span class="token operator" style="color:#393A34">&lt;-</span><span class="token plain"> item</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">true</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">default</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token comment" style="color:#999988;font-style:italic">// Evict the oldest message, then enqueue the new one. Retry twice: a</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token comment" style="color:#999988;font-style:italic">// concurrent fast-path sender (one that hasn't hit this lock at all)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token comment" style="color:#999988;font-style:italic">// can still slide into the slot we just freed before our own enqueue</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token comment" style="color:#999988;font-style:italic">// runs, so the first evict+enqueue attempt can lose the race and needs</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token comment" style="color:#999988;font-style:italic">// a second try rather than giving up and dropping the new message too.</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">for</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">range</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">2</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">select</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">case</span><span class="token plain"> dropped </span><span class="token operator" style="color:#393A34">:=</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&lt;-</span><span class="token plain">client</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">send</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            h</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">notifySendDropped</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">client</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> dropped</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">data</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">default</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">select</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">case</span><span class="token plain"> client</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">send </span><span class="token operator" style="color:#393A34">&lt;-</span><span class="token plain"> item</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">true</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">default</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">false</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<p>The mutex only has to serialize <em>this</em> path against <em>itself</em> — the
non-blocking fast path everywhere else still runs lock-free, so <code>DropNewest</code>
(the default) pays none of this cost, and <code>DropOldest</code> pays it only on the
buffer-full path, not on every send. That's the actual tradeoff <code>DropOldest</code>
buys you: strictly-correct "keep only the newest N" semantics, at the cost
of a mutex acquisition exactly when a client is already struggling to keep
up — which is the one time you can least afford a slow send path, and
precisely why the retry logic exists instead of a simpler "evict once, give
up if that races."</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="choosing-a-policy-is-a-semantics-question-not-a-performance-one">Choosing a policy is a semantics question, not a performance one<a href="https://kartikrocks.github.io/wshub/blog/building-backpressure-into-a-go-websocket-server#choosing-a-policy-is-a-semantics-question-not-a-performance-one" class="hash-link" aria-label="Direct link to Choosing a policy is a semantics question, not a performance one" title="Direct link to Choosing a policy is a semantics question, not a performance one" translate="no">​</a></h2>
<p>Given the two policies work at different cost, it's tempting to treat
<code>DropNewest</code> as the "cheap" default and <code>DropOldest</code> as an opt-in for when
you need it. That's true, but it's not the deciding factor — the deciding
factor is what a dropped message <em>means</em> for your protocol:</p>
<ul>
<li class=""><strong><code>DropNewest</code></strong> is correct for append-only streams — chat messages, log
lines, discrete events — where every message is independently meaningful
and losing the newest of several queued ones is recoverable; the client
still gets a consistent, if incomplete, history.</li>
<li class=""><strong><code>DropOldest</code></strong> is correct for state-replacement streams — a live cursor
position, a stock price, a presence indicator — where only the <em>current</em>
value matters and delivering a stale one is actively wrong. Here, dropping
the <em>new</em> message under <code>DropNewest</code> would mean showing the client
outdated state for longer, which is worse than showing it fewer updates.</li>
</ul>
<p>Get this backwards — <code>DropOldest</code> on a chat feed, say — and you get subtly
corrupted output (a client that sees "waves goodbye" without ever having
seen "says hello") instead of a clean gap, which is a harder bug to notice
in code review and a harder one to explain in an incident writeup.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="a-drop-is-not-allowed-to-be-silent">A drop is not allowed to be silent<a href="https://kartikrocks.github.io/wshub/blog/building-backpressure-into-a-go-websocket-server#a-drop-is-not-allowed-to-be-silent" class="hash-link" aria-label="Direct link to A drop is not allowed to be silent" title="Direct link to A drop is not allowed to be silent" translate="no">​</a></h2>
<p>Every drop, on either policy, runs through one function:</p>
<div class="language-go codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-go codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">func</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">h </span><span class="token operator" style="color:#393A34">*</span><span class="token plain">Hub</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">notifySendDropped</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">client </span><span class="token operator" style="color:#393A34">*</span><span class="token plain">Client</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> data </span><span class="token punctuation" style="color:#393A34">[</span><span class="token punctuation" style="color:#393A34">]</span><span class="token builtin">byte</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    h</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">logger</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">Warn</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"Client send buffer full, message dropped"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"clientID"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> client</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">ID</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    h</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">metrics</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">IncrementMessagesDropped</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> h</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">hooks</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">OnSendDropped </span><span class="token operator" style="color:#393A34">!=</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">nil</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        h</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">hooks</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">OnSendDropped</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">client</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> data</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<p>Three separate outlets, and each earns its place:</p>
<ul>
<li class=""><strong>The metric</strong> (<code>wshub_messages_dropped_total</code> in the Prometheus
subpackage) is what you graph and alert on. A drop rate that's zero for
weeks and then climbs is a real signal — a client of yours changed
behavior, or you're underprovisioned for current traffic — and it's
invisible without this.</li>
<li class=""><strong>The hook</strong> (<code>OnSendDropped</code>) is the extension point for a policy
decision the library can't make for you: disconnect a client after N
consecutive drops, downgrade them to a lower-fidelity message format, or
page someone if drops start hitting a specific high-value client. This is
where <code>client.CloseWithCode</code> earns its keep, called from inside the hook.</li>
<li class=""><strong>The log line</strong> is for incident diagnosis — which client, roughly when.</li>
</ul>
<p>Worth knowing before you rely on it in production: that log line is not
rate-limited. It's a distinct code path from wshub's handshake-failure
logging, which <em>is</em> explicitly rate-limited — because a failed handshake is
something an unauthenticated client can trigger for free — while a message
drop only happens for a client the hub has already accepted and is actively
serving. That's a reasonable place to draw the line, but it also means a
genuinely pathological slow client under sustained broadcast load can
produce one <code>Warn</code> line per dropped message. If you're running at a scale
where that's a real concern, the fix isn't in the log line — it's sampling
or de-duplicating in your own <code>OnSendDropped</code> hook, which sees every drop
before you decide whether it's worth another log entry.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-this-buys-you">What this buys you<a href="https://kartikrocks.github.io/wshub/blog/building-backpressure-into-a-go-websocket-server#what-this-buys-you" class="hash-link" aria-label="Direct link to What this buys you" title="Direct link to What this buys you" translate="no">​</a></h2>
<p>None of this changes the big picture from article one: bound the buffer,
make the send non-blocking, pick a drop policy that matches your protocol's
semantics, and make every drop observable. What changes with the code above
is confidence that the mechanism is actually correct under concurrency, not
just correct in the case your tests happened to exercise — a
time-of-check-to-time-of-use race and a lost-update race are exactly the
kind of bugs that pass every test you write single-threaded and show up
three weeks into production, at 2 a.m., under real concurrent load.</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">go get github.com/KARTIKrocks/wshub</span><br></div></code></pre></div></div>
<p>The full <code>DropPolicy</code> and hook reference is in the
<a href="https://kartikrocks.github.io/wshub/docs/hub#drop-policy" target="_blank" rel="noopener noreferrer" class="">docs</a>, and the
send-path benchmarks referenced above are reproducible with
<code>go test -bench=. -benchmem ./...</code> — methodology in the
<a href="https://github.com/KARTIKrocks/wshub#benchmarks" target="_blank" rel="noopener noreferrer" class="">README</a>.</p>]]></content>
        <author>
            <name>Kartik</name>
            <uri>https://github.com/KARTIKrocks</uri>
        </author>
        <category label="go" term="go"/>
        <category label="websocket" term="websocket"/>
        <category label="concurrency" term="concurrency"/>
        <category label="reliability" term="reliability"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Graceful WebSocket Shutdown in Go]]></title>
        <id>https://kartikrocks.github.io/wshub/blog/graceful-websocket-shutdown-in-go</id>
        <link href="https://kartikrocks.github.io/wshub/blog/graceful-websocket-shutdown-in-go"/>
        <updated>2026-09-11T00:00:00.000Z</updated>
        <summary type="html"><![CDATA["Wait for every connection to close on its own" sounds graceful and isn't — a client that's connected but silent can block it forever. The actual implementation needs a state machine, an idle reaper, and a shutdown path that unblocks it.]]></summary>
        <content type="html"><![CDATA[<p><a class="" href="https://kartikrocks.github.io/wshub/blog/production-grade-websocket-server-in-go">Article one</a> in this series
showed the two-phase pattern — <code>Drain</code> then <code>Shutdown</code> — as the answer to
"what happens to 8,000 open connections when a deploy kills the pod."
This one is about why it needs to be two phases with real machinery behind
each, not a <code>sync.WaitGroup.Wait()</code> and a signal handler. The naive version
— stop accepting connections, wait for every existing one to close on its
own — sounds graceful and has a hole in it big enough to hang your rollout:
a client that's connected but silent, sending and receiving nothing, never
triggers a natural disconnect. Wait for it to leave on its own and you're
not draining gracefully, you're waiting for Kubernetes' own termination
grace period to expire and <code>SIGKILL</code> you anyway — which is exactly the
outcome graceful shutdown exists to avoid.</p>
<!-- -->
<p>This is article five in a series on production-grade WebSocket servers in
Go — see <a class="" href="https://kartikrocks.github.io/wshub/blog/production-grade-websocket-server-in-go">article one</a> for
the full list of problems this series covers, and
<a class="" href="https://kartikrocks.github.io/wshub/blog/handling-10000-websocket-connections-in-go">articles two</a>,
<a class="" href="https://kartikrocks.github.io/wshub/blog/building-backpressure-into-a-go-websocket-server">three</a>, and
<a class="" href="https://kartikrocks.github.io/wshub/blog/scaling-websockets-across-multiple-go-servers-with-redis">four</a> for
connection scaling, backpressure, and multi-node delivery. This one walks
through <a href="https://github.com/KARTIKrocks/wshub" target="_blank" rel="noopener noreferrer" class="">wshub</a>'s actual <code>Drain</code>/
<code>Shutdown</code> implementation and the specific races each piece of it defends
against.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="a-state-machine-not-a-bool">A state machine, not a bool<a href="https://kartikrocks.github.io/wshub/blog/graceful-websocket-shutdown-in-go#a-state-machine-not-a-bool" class="hash-link" aria-label="Direct link to A state machine, not a bool" title="Direct link to A state machine, not a bool" translate="no">​</a></h2>
<p>A hub is in exactly one of three states — <code>StateRunning</code>,
<code>StateDraining</code>, <code>StateStopped</code> — stored as a single atomic <code>int32</code> rather
than guarded by a mutex. That choice matters on the read side: <code>Health()</code>
and <code>Ready()</code> are meant to answer instantly under real concurrent load
(a Kubernetes probe hitting <code>/readyz</code> every few seconds shouldn't contend
with connection-registration locks), so every state read is a lock-free
atomic load, not a mutex acquisition.</p>
<p>The transition into draining uses a compare-and-swap, and the reason is
concurrency, not style:</p>
<div class="language-go codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-go codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">!</span><span class="token plain">h</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">state</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">CompareAndSwap</span><span class="token punctuation" style="color:#393A34">(</span><span class="token function" style="color:#d73a49">int32</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">StateRunning</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">int32</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">StateDraining</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    current </span><span class="token operator" style="color:#393A34">:=</span><span class="token plain"> h</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">State</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> current </span><span class="token operator" style="color:#393A34">==</span><span class="token plain"> StateStopped </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">nil</span><span class="token plain"> </span><span class="token comment" style="color:#999988;font-style:italic">// already shut down</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token comment" style="color:#999988;font-style:italic">// current == StateDraining: fall through to wait on the same signal.</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">else</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token comment" style="color:#999988;font-style:italic">// We won the CAS — we are the drain initiator.</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token comment" style="color:#999988;font-style:italic">// ...start the idle reaper, etc.</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<p><code>Drain</code> is safe to call more than once, including concurrently, because
exactly one caller wins the CAS and becomes "the initiator" — the one that
logs the start, checks for the zero-clients fast path, and spawns the idle
reaper. Every other caller, including one that arrives after draining has
already started, just falls through to waiting on the same completion
signal. Without the CAS, two goroutines calling <code>Drain</code> at the same time —
plausible if both an HTTP <code>preStop</code> hook and a signal handler try to
initiate shutdown — would both start their own idle reaper, wastefully and
redundantly walking the same client list on separate tickers.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="one-completion-signal-closed-exactly-once">One completion signal, closed exactly once<a href="https://kartikrocks.github.io/wshub/blog/graceful-websocket-shutdown-in-go#one-completion-signal-closed-exactly-once" class="hash-link" aria-label="Direct link to One completion signal, closed exactly once" title="Direct link to One completion signal, closed exactly once" translate="no">​</a></h2>
<p>That completion signal is a channel, <code>drainDone</code>, and it has two independent
writers: <code>Drain</code> itself closes it when every client has disconnected, and
<code>Shutdown</code> closes it to force-unblock any <code>Drain</code> call still waiting — for
instance if the drain timeout is about to expire and something needs to
force the issue immediately. Closing a channel that's already closed
panics, so both call sites go through the same <code>sync.Once</code>:</p>
<div class="language-go codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-go codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">h</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">drainOnce</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">Do</span><span class="token punctuation" style="color:#393A34">(</span><span class="token keyword" style="color:#00009f">func</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">close</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">h</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">drainDone</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<p>This is a small detail with an easy failure mode if you write it yourself
without a guard: exactly one of "drain finished naturally" or "shutdown
forced it closed" will happen first in any given run, and which one wins is
a race by construction — the whole point is that either path can trigger
completion. Coordinating that through <code>sync.Once</code> rather than a manual
"only close if not already closed" check with a boolean flag is what makes
it correct under the race instead of merely correct in whichever order you
happened to test.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-idle-reaper-what-graceful-actually-requires">The idle reaper: what "graceful" actually requires<a href="https://kartikrocks.github.io/wshub/blog/graceful-websocket-shutdown-in-go#the-idle-reaper-what-graceful-actually-requires" class="hash-link" aria-label="Direct link to The idle reaper: what &quot;graceful&quot; actually requires" title="Direct link to The idle reaper: what &quot;graceful&quot; actually requires" translate="no">​</a></h2>
<p>Here's the mechanism that turns "wait for clients to disconnect" from a
hope into a bound. During drain, a background goroutine periodically checks
every client's outbound send buffer:</p>
<div class="language-go codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-go codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">for</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">_</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> client </span><span class="token operator" style="color:#393A34">:=</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">range</span><span class="token plain"> snap</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">slice </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">len</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">client</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">send</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">==</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">0</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">_</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> tracked </span><span class="token operator" style="color:#393A34">:=</span><span class="token plain"> firstIdleAt</span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain">client</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">!</span><span class="token plain">tracked </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            firstIdleAt</span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain">client</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> now</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">else</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> now</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">Sub</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">firstIdleAt</span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain">client</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">&gt;=</span><span class="token plain"> h</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">drainTimeout </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token boolean" style="color:#36acaa">_</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> client</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">CloseWithCode</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">websocket</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">CloseGoingAway</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"server draining"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">            </span><span class="token function" style="color:#d73a49">delete</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">firstIdleAt</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> client</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">else</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token function" style="color:#d73a49">delete</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">firstIdleAt</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> client</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token comment" style="color:#999988;font-style:italic">// has pending work — not idle</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<p>Precision matters here: this measures the <em>send</em> buffer being continuously
empty — "the server has had nothing left to tell this client for the full
drain timeout" — not literally whether the client is responsive. That's a
deliberate, narrower signal than "is this client still alive," and it's the
right one for this job: during a drain you're not trying to detect a broken
client, you're trying to bound how long you wait for ordinary, well-behaved
connections that simply have no current reason to disconnect on their own.
A chat client sitting on an open tab is not going to close its own socket
just because your server would like to restart — proactively closing it
with <code>CloseGoingAway</code> (1001), a real WebSocket close code, is what lets it
distinguish "the server said goodbye" from a dropped connection and
reconnect cleanly.</p>
<p>The check interval is derived from the timeout itself, floored so short
timeouts (tests, aggressive rollout policies) stay responsive:</p>
<div class="language-go codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-go codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">interval </span><span class="token operator" style="color:#393A34">:=</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">max</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">h</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">drainTimeout</span><span class="token operator" style="color:#393A34">/</span><span class="token number" style="color:#36acaa">4</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">100</span><span class="token operator" style="color:#393A34">*</span><span class="token plain">time</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">Millisecond</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<p>With the default 30-second drain timeout that's a 7.5-second tick, and the
worst case costs two of them, not one: a connection that goes idle right
after a tick can wait up to a full interval before <code>firstIdleAt</code> even gets
recorded, and the close itself only fires on a later tick too — so the real
bound on top of <code>drainTimeout</code> is up to <code>2 × interval</code> (~15s here), not
just one. That's a concrete, quantifiable bound you can reason about when
setting your own <code>preStop</code>
grace period, not an unspecified "eventually."</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="shutdown-the-part-that-actually-forces-connections-closed">Shutdown: the part that actually forces connections closed<a href="https://kartikrocks.github.io/wshub/blog/graceful-websocket-shutdown-in-go#shutdown-the-part-that-actually-forces-connections-closed" class="hash-link" aria-label="Direct link to Shutdown: the part that actually forces connections closed" title="Direct link to Shutdown: the part that actually forces connections closed" translate="no">​</a></h2>
<p><code>Drain</code> alone never force-closes an active connection — only idle ones past
the timeout. Whatever's left when the context passed to <code>Drain</code> expires is
<code>Shutdown</code>'s job, and it doesn't loop over clients calling <code>Close()</code>
individually. It cancels the hub's shared context:</p>
<div class="language-go codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-go codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">h</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">state</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">Store</span><span class="token punctuation" style="color:#393A34">(</span><span class="token function" style="color:#d73a49">int32</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">StateStopped</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">h</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">drainOnce</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">Do</span><span class="token punctuation" style="color:#393A34">(</span><span class="token keyword" style="color:#00009f">func</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">close</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">h</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">drainDone</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token comment" style="color:#999988;font-style:italic">// unblock any pending Drain</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">h</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">cancel</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<p>That single <code>cancel()</code> is what tears down every remaining connection, and
it works the same way <a class="" href="https://kartikrocks.github.io/wshub/blog/scaling-websockets-across-multiple-go-servers-with-redis">article four</a>'s
Redis adapter closes its subscription: a small watcher goroutine per client
does nothing but wait on <code>ctx.Done()</code> and then call <code>closeConn()</code>, which is
what actually unblocks the <code>readPump</code>'s blocking <code>ReadMessage()</code> call. It's
the same shape — a goroutine whose only job is to close something from
<em>outside</em> the blocking loop that's holding it — showing up twice in the
same codebase because it's the correct answer to the same underlying
problem: you can't make a blocking call return early from inside itself, so
something else has to close the resource it's blocked on.</p>
<p>One ordering detail in <code>Shutdown</code> is there specifically because of that
same adapter mechanism:</p>
<div class="language-go codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-go codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token comment" style="color:#999988;font-style:italic">// Close the adapter before waiting on goroutines — the subscriber</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic">// goroutine may block on a channel read that only unblocks on close.</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> h</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">adapter </span><span class="token operator" style="color:#393A34">!=</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">nil</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> err </span><span class="token operator" style="color:#393A34">:=</span><span class="token plain"> h</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">adapter</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">Close</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> err </span><span class="token operator" style="color:#393A34">!=</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">nil</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        h</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">logger</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">Error</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"adapter close failed"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"error"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> err</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<p>If <code>Shutdown</code> waited on its own <code>WaitGroup</code> first and closed the adapter
after, it could deadlock against exactly the pattern described in article
four: the adapter's receive goroutine is parked on <code>sub.Channel()</code>, which
only unblocks when something calls <code>sub.Close()</code> — and that call lives
inside the adapter's own <code>Close()</code>, not inside the hub. Closing the adapter
first guarantees that call happens before anything waits for it to finish.</p>
<p>Everything after that is bookkeeping in the right order — the worker pool
(if <code>WithParallelBroadcast</code> was configured) drains its remaining queued
work before its own workers exit, then <code>Shutdown</code> waits on the hub's
<code>WaitGroup</code> bounded by the passed context, returning <code>nil</code> on clean exit or
the context's error if the deadline hits first — the same
"select on completion vs. context" shape as <code>Drain</code>.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="putting-it-back-together">Putting it back together<a href="https://kartikrocks.github.io/wshub/blog/graceful-websocket-shutdown-in-go#putting-it-back-together" class="hash-link" aria-label="Direct link to Putting it back together" title="Direct link to Putting it back together" translate="no">​</a></h2>
<p>The two-phase call from article one now has a fuller explanation behind it:</p>
<div class="language-go codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-go codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">ctx</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> cancel </span><span class="token operator" style="color:#393A34">:=</span><span class="token plain"> context</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">WithTimeout</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">context</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">Background</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">30</span><span class="token operator" style="color:#393A34">*</span><span class="token plain">time</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">Second</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">defer</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">cancel</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">hub</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">Drain</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">ctx</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain">    </span><span class="token comment" style="color:#999988;font-style:italic">// stop new connections; idle ones get CloseGoingAway</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                  </span><span class="token comment" style="color:#999988;font-style:italic">// after drainTimeout; active ones finish on their own</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">hub</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">Shutdown</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">ctx</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token comment" style="color:#999988;font-style:italic">// cancel the shared context — force-closes whatever's left,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                  </span><span class="token comment" style="color:#999988;font-style:italic">// closing the adapter first so its own goroutines can exit</span><br></div></code></pre></div></div>
<p>Skipping <code>Drain</code> and calling <code>Shutdown</code> alone is a valid, different choice —
every connection gets force-closed immediately via context cancellation,
with no <code>CloseGoingAway</code> grace period. That's the right call for a genuine
emergency stop; it's the wrong one for a routine rolling deploy, which is
why the two are separate methods instead of one with a flag.</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">go get github.com/KARTIKrocks/wshub</span><br></div></code></pre></div></div>
<p>Full reference for <code>Drain</code>, <code>Shutdown</code>, and <code>WithDrainTimeout</code> is in the
<a href="https://kartikrocks.github.io/wshub/docs/hub#graceful-drain" target="_blank" rel="noopener noreferrer" class="">Hub docs</a>.
That closes out this five-part series — from the eight problems a
production WebSocket server has to solve, through connection scaling,
backpressure, multi-node delivery, and now shutdown. The
<a href="https://github.com/KARTIKrocks/wshub" target="_blank" rel="noopener noreferrer" class="">README</a> has the full API surface and
benchmark methodology for anything not covered along the way.</p>]]></content>
        <author>
            <name>Kartik</name>
            <uri>https://github.com/KARTIKrocks</uri>
        </author>
        <category label="go" term="go"/>
        <category label="websocket" term="websocket"/>
        <category label="concurrency" term="concurrency"/>
        <category label="reliability" term="reliability"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[How to Handle 10,000 WebSocket Connections in Go]]></title>
        <id>https://kartikrocks.github.io/wshub/blog/handling-10000-websocket-connections-in-go</id>
        <link href="https://kartikrocks.github.io/wshub/blog/handling-10000-websocket-connections-in-go"/>
        <updated>2026-09-11T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[10,000 concurrent connections stopped being a hard problem for Go's runtime a decade ago. What's left is a small, checkable list of OS limits and memory arithmetic — here's the actual accounting.]]></summary>
        <content type="html"><![CDATA[<p>"Can it handle 10,000 connections?" gets asked about every realtime service,
usually as a stand-in for "is this production-grade." It's the wrong question
in one specific way: for a Go server, holding 10,000 idle-ish WebSocket
connections open was never the hard part. The C10K problem — the one that
gave the number its mythology — was about thread-per-connection servers
where each connection cost an OS thread and a context switch. Go's runtime
solved that structurally before you wrote a line of your handler. What's
left is arithmetic: how much memory each connection actually costs, and
which OS limits you'll hit before your own code does.</p>
<!-- -->
<p>This is article two in a series on what "production-grade" means for a Go
WebSocket server — <a class="" href="https://kartikrocks.github.io/wshub/blog/production-grade-websocket-server-in-go">the first one</a>
listed eight problems production traffic surfaces. This one goes deep on the
first number everyone asks about, using <a href="https://github.com/KARTIKrocks/wshub" target="_blank" rel="noopener noreferrer" class="">wshub</a>'s
own published load-test numbers as the concrete case.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why-the-c10k-framing-is-stale">Why the C10K framing is stale<a href="https://kartikrocks.github.io/wshub/blog/handling-10000-websocket-connections-in-go#why-the-c10k-framing-is-stale" class="hash-link" aria-label="Direct link to Why the C10K framing is stale" title="Direct link to Why the C10K framing is stale" translate="no">​</a></h2>
<p>The original C10K problem (Dan Kegel, 1999) was about servers that spent one
OS thread per connection. Ten thousand threads meant ten thousand ~1–8 MB
stacks and a kernel scheduler context-switching between them — real
resource pressure, independent of what the handler actually did.</p>
<p>Go doesn't have that problem by construction. A goroutine's stack starts at
2 KB and grows on demand; the runtime's own scheduler multiplexes goroutines
onto a small, <code>GOMAXPROCS</code>-bound pool of OS threads. Critically, a goroutine
blocked on <code>conn.Read()</code> or <code>conn.Write()</code> doesn't block an OS thread at
all — the runtime's netpoller (backed by <code>epoll</code> on Linux, <code>kqueue</code> on
BSD/macOS) parks it and reuses the thread for other goroutines until the
socket is actually readable or writable. Ten thousand goroutines blocked on
network I/O cost ten thousand parked stacks, not ten thousand blocked
threads. This is what makes goroutine-per-connection — rather than an
event-loop-with-callbacks architecture — a reasonable default in Go, when
the same model was a liability in C or early Java.</p>
<p>So "can Go handle 10K connections" was already answered by the runtime.
The real questions are narrower:</p>
<ol>
<li class="">How much memory does each connection actually cost, end to end?</li>
<li class="">What OS-level limits sit between your process and 10,000 open sockets?</li>
<li class="">At what point does something <em>other</em> than connection count become the
bottleneck?</li>
</ol>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-actual-memory-budget">The actual memory budget<a href="https://kartikrocks.github.io/wshub/blog/handling-10000-websocket-connections-in-go#the-actual-memory-budget" class="hash-link" aria-label="Direct link to The actual memory budget" title="Direct link to The actual memory budget" translate="no">​</a></h2>
<p>wshub's connection handler is exactly two long-lived goroutines per client —
a <code>readPump</code> blocked in <code>conn.ReadMessage()</code>, and a <code>writePump</code> draining a
buffered <code>chan []byte</code> and owning all writes to the socket (<code>gorilla/websocket</code>
requires a single writer; concurrent writes from two goroutines is a data
race the library doesn't protect against). The HTTP handler goroutine that
performed the upgrade returns immediately after spawning those two — it
does not stick around, so it's not part of the steady-state cost.</p>
<p>Per connection, that's:</p>
<ul>
<li class="">Two goroutine stacks (2 KB initial, growing only if a call frame needs it —
in practice these pumps stay shallow).</li>
<li class="">Two <code>gorilla/websocket</code> I/O buffers, 1 KB each by default
(<code>ReadBufferSize</code>/<code>WriteBufferSize</code> in wshub's <code>Config</code>).</li>
<li class="">A buffered send channel, <code>SendChannelSize</code> slots deep (default 256) — note
this holds <code>[]byte</code> <em>references</em>, not preallocated byte arrays, so it costs
roughly 256 × 24 bytes of slice headers when full, not 256 KB.</li>
<li class="">Bookkeeping: a map entry in the hub's client registry, connection metadata,
and whatever callbacks are registered.</li>
</ul>
<p>Measuring this in aggregate rather than adding up estimates is more honest,
so here's what wshub's end-to-end load test actually measures (real
<code>gorilla/websocket</code> dialer connections against a real <code>httptest.Server</code>,
memory sampled via <code>runtime.MemStats</code> and divided by connection count):</p>
<table><thead><tr><th>Clients</th><th>Connect time</th><th>Rate</th><th>Mem/conn</th></tr></thead><tbody><tr><td>1,000</td><td>59 ms</td><td>15,754 conn/s</td><td>27.1 KB</td></tr><tr><td>5,000</td><td>162 ms</td><td>29,853 conn/s</td><td>24.0 KB</td></tr><tr><td>10,000</td><td>263 ms</td><td>36,891 conn/s</td><td>25.9 KB</td></tr></tbody></table>
<p>Two things worth noticing. First, per-connection memory is flat across an
order of magnitude of scale (~25–27 KB) — there's no hidden O(n) cost
lurking in the client registry or the broadcast path that shows up only at
higher connection counts. Second, handshake <em>throughput</em> actually improves
with scale (15.7K → 36.9K conn/s), which is a load-test-harness artifact, not
a server property: connecting more clients amortizes fixed startup costs
(goroutine scheduling ramp-up, initial GC assist) over more connections. At
10,000 clients that's still only ~260 ms wall-clock to establish every
connection.</p>
<p>Multiply 25.9 KB by however many connections you're planning for and you
have a real memory floor: 100,000 idle connections is roughly 2.6 GB just
for connection-holding, before your application logic allocates anything.
That's the number to put in a capacity plan, not a guess.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-os-limits-in-the-order-youll-hit-them">The OS limits, in the order you'll hit them<a href="https://kartikrocks.github.io/wshub/blog/handling-10000-websocket-connections-in-go#the-os-limits-in-the-order-youll-hit-them" class="hash-link" aria-label="Direct link to The OS limits, in the order you'll hit them" title="Direct link to The OS limits, in the order you'll hit them" translate="no">​</a></h2>
<p>None of these are Go-specific, and all of them are checkable before you
find out about them from an outage.</p>
<p><strong>1. File descriptor limit (<code>ulimit -n</code>).</strong> Every WebSocket connection is one
socket, and every socket is one file descriptor. Most Linux distributions
default the per-process soft limit to 1024 — far below 10,000. Check it with
<code>ulimit -n</code>, and raise it wherever the process actually starts:
<code>LimitNOFILE=65536</code> in a systemd unit, <code>--ulimit nofile=65536:65536</code> for
Docker, or <code>ulimit -n 65536</code> before exec in a shell-launched process. Budget
headroom above your target connection count for log files, outbound
connections to Redis/NATS if you're using an adapter, and health-check
sockets.</p>
<p><strong>2. Listen backlog (<code>net.core.somaxconn</code>).</strong> This one only bites during a
<em>connection burst</em>, not at steady state. The kernel queues completed
TCP handshakes for <code>accept()</code> in a backlog whose size is capped by
<code>net.core.somaxconn</code> (historically 128 on older kernels, higher on modern
defaults); Go's <code>net.Listen</code> requests a backlog but the kernel silently caps
it at this value. If 5,000 clients try to connect within the same
handshake-timeout window — a mobile app coming back online after a network
blip, a game client reconnect storm — and your backlog is smaller than the
burst, the kernel starts dropping or refusing the excess before your accept
loop ever sees them. This is exactly why wshub's own load-test client caps
itself to 200 concurrent in-flight handshakes with a semaphore: not because
the server can't take more, but because an unbounded connect burst measures
the <em>test harness's</em> backlog behavior instead of the server's. In
production, size <code>somaxconn</code> to your worst realistic reconnect-storm burst,
not your steady-state connection count.</p>
<p><strong>3. Ephemeral ports — but check which side actually needs them.</strong> This is
where a lot of write-ups get it backwards. A listening server doesn't
consume an ephemeral port per accepted connection — every inbound connection
shares the same server-side <code>(IP, port)</code> pair; what makes each connection
unique is the <em>client's</em> IP and port. The ephemeral port range
(<code>net.ipv4.ip_local_port_range</code> on Linux, roughly 28,000–60,000 ports by
default) only matters for the side <em>initiating</em> outbound connections: a
load-generator opening 10,000 client sockets from one machine, or your
server's own outbound connections to a Redis/NATS adapter. If you're only
accepting inbound WebSocket connections, this limit is irrelevant to your
connection ceiling — don't spend time tuning it based on advice written for
a different topology.</p>
<p><strong>4. <code>GOMAXPROCS</code> and the scheduler, not connection count.</strong> wshub's load
test header prints <code>12 cores</code> on the machine these numbers came from — this
matters for <em>dispatch</em> work, not for holding connections open. A goroutine
parked on a socket read costs no CPU; a goroutine actively processing a
message does. The next section is where this actually shows up.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="where-10000-stops-being-just-a-number">Where 10,000 stops being "just a number"<a href="https://kartikrocks.github.io/wshub/blog/handling-10000-websocket-connections-in-go#where-10000-stops-being-just-a-number" class="hash-link" aria-label="Direct link to Where 10,000 stops being &quot;just a number&quot;" title="Direct link to Where 10,000 stops being &quot;just a number&quot;" translate="no">​</a></h2>
<p>Holding 10,000 idle connections open is close to free. <em>Doing something</em>
with all of them concurrently is a different problem, and it's where wshub's
own benchmarks show a real cliff — not in connection count, but in
sustained fanout:</p>
<table><thead><tr><th>Clients</th><th>Throughput</th><th>p50</th><th>p95</th><th>p99</th></tr></thead><tbody><tr><td>1,000</td><td>100,000 msg/s</td><td>1.48 ms</td><td>1.85 ms</td><td>2.75 ms</td></tr><tr><td>5,000</td><td>499,500 msg/s</td><td>7.91 ms</td><td>19.0 ms</td><td>31.8 ms</td></tr><tr><td>10,000</td><td>693,900 msg/s</td><td>1.72 s</td><td>3.19 s</td><td>3.34 s</td></tr></tbody></table>
<p>Throughput keeps climbing, but p50 latency jumps almost three orders of
magnitude between 5,000 and 10,000 clients under sustained broadcast load
(100 msg/s from a single broadcaster, 10-second window). The documented
explanation is scheduler pressure: every broadcast at this scale is pushing
into thousands of buffered channels, and <em>reading</em> off each of those
channels and writing to each socket is real, concurrently-scheduled CPU and
syscall work — competing for the same <code>GOMAXPROCS</code>-bound thread pool that
every other goroutine in the process shares, including the read side of the
load-test harness itself. The hub's own dispatch loop stays allocation-free
at this scale (the in-process broadcast micro-benchmarks confirm that part
in isolation); the cost is downstream of dispatch, in the fan of individual
socket writes.</p>
<p>The practical implication: "10,000 connections" and "10,000 connections all
receiving a broadcast every 10ms" are different capacity questions, and the
second one is the one that actually constrains a single node. wshub exposes
three knobs for this, in the order I'd reach for them:</p>
<ul>
<li class=""><strong><code>SendChannelSize</code></strong> — a deeper per-client buffer absorbs short bursts
without invoking the drop policy, at the cost of more memory per
connection and, if actually filled, more end-to-end latency for whatever's
sitting in the queue.</li>
<li class=""><strong><code>WithCoalesceWrites(true)</code></strong> — batches queued text messages into a
single WebSocket frame (newline-separated) before they hit the socket,
trading a small amount of client-side parsing for fewer syscalls per
connection under high message rates.</li>
<li class=""><strong>Horizontal scaling</strong> — past a real per-node ceiling, add nodes behind a
Redis or NATS adapter rather than tuning a single process further. That's
its own set of correctness problems (node-ID deduplication, not blocking
local delivery on the bus) — enough for the next article in this series.</li>
</ul>
<p>One knob that's <em>not</em> on this list: <code>WithParallelBroadcast</code>. It exists for
backward compatibility, but wshub's own load tests found it consistently
slower than the default serial broadcast in practice — the per-call
lock/defer overhead of parallel dispatch doesn't pay for itself against real
sockets, even though it looks like it should help on paper. Measure before
reaching for parallelism; the bottleneck here isn't the dispatch loop.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-checklist">The checklist<a href="https://kartikrocks.github.io/wshub/blog/handling-10000-websocket-connections-in-go#the-checklist" class="hash-link" aria-label="Direct link to The checklist" title="Direct link to The checklist" translate="no">​</a></h2>
<p>Getting a Go process to comfortably hold 10,000 WebSocket connections is not
a performance-engineering project. It's five checks:</p>
<ol>
<li class=""><strong><code>ulimit -n</code></strong> raised above your target connection count plus headroom,
at the layer that actually launches the process (systemd/Docker/shell).</li>
<li class=""><strong><code>net.core.somaxconn</code></strong> sized to your worst realistic reconnect burst,
not your steady-state count.</li>
<li class=""><strong>Memory budget</strong> = connection count × ~26 KB (adjust for your own
<code>SendChannelSize</code>/buffer configuration) — a real number for capacity
planning, not a guess.</li>
<li class=""><strong>Don't tune ephemeral ports on the server</strong> unless it's also making
10,000 outbound connections — that limit belongs to whoever dials, not
whoever listens.</li>
<li class=""><strong>Benchmark fanout, not just connect</strong> — a server holding 10,000 idle
connections and one broadcasting to all of them every 10ms are different
capacity questions, and the second is where you'll actually find your
ceiling.</li>
</ol>
<p>Every default referenced here — buffer sizes, channel depth, coalescing,
drop policy — is a <code>wshub.Config</code> or <code>Hub</code> option, not something you have to
reimplement to test for yourself:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">go get github.com/KARTIKrocks/wshub</span><br></div></code></pre></div></div>
<p>Reproduce the numbers in this post directly:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">make</span><span class="token plain"> loadtest </span><span class="token assign-left variable" style="color:#36acaa">LOADTEST_ARGS</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">"-scenario connect -clients 10000"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token function" style="color:#d73a49">make</span><span class="token plain"> loadtest </span><span class="token assign-left variable" style="color:#36acaa">LOADTEST_ARGS</span><span class="token operator" style="color:#393A34">=</span><span class="token string" style="color:#e3116c">"-scenario fanout -clients 10000"</span><br></div></code></pre></div></div>
<p>Full configuration reference is in the
<a href="https://kartikrocks.github.io/wshub/docs/configuration" target="_blank" rel="noopener noreferrer" class="">docs</a>, and the
complete benchmark methodology is in the
<a href="https://github.com/KARTIKrocks/wshub#benchmarks" target="_blank" rel="noopener noreferrer" class="">README</a>.</p>]]></content>
        <author>
            <name>Kartik</name>
            <uri>https://github.com/KARTIKrocks</uri>
        </author>
        <category label="go" term="go"/>
        <category label="websocket" term="websocket"/>
        <category label="performance" term="performance"/>
        <category label="systems" term="systems"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Building a Production-Grade WebSocket Server in Go]]></title>
        <id>https://kartikrocks.github.io/wshub/blog/production-grade-websocket-server-in-go</id>
        <link href="https://kartikrocks.github.io/wshub/blog/production-grade-websocket-server-in-go"/>
        <updated>2026-09-11T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[What actually breaks when a `gorilla/websocket` demo meets production traffic, and the eight problems you have to solve — in order — to fix it.]]></summary>
        <content type="html"><![CDATA[<p>Every Go WebSocket server starts the same way. You pull in <code>gorilla/websocket</code>
or <code>nhooyr.io/websocket</code>, write an <code>Upgrade</code> call, spin up a goroutine that
reads frames in a loop, and it works. You broadcast a message by iterating a
<code>map[*Client]bool</code> and calling <code>conn.WriteMessage</code> on each one. Twenty lines,
a working demo, and a reasonable belief that the hard part is behind you.</p>
<p>It isn't. The hard part starts at the traffic your demo never saw: a client
that stops reading, a deploy that needs to happen without dropping
connections, a second replica that has no idea the first one exists. None of
that is in the WebSocket RFC, and none of it is optional once real users are
on the other end of the socket.</p>
<!-- -->
<p>This post is the list of problems I hit, in the order production traffic
surfaces them, and what each one actually requires — not "add a mutex," but
the specific mechanism and the tradeoff it forces. I ended up packaging the
result as an open-source library, <a href="https://github.com/KARTIKrocks/wshub" target="_blank" rel="noopener noreferrer" class="">wshub</a>,
but the problems here apply whether you use it or write your own.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="1-the-connection-lifecycle-is-two-goroutines-not-one">1. The connection lifecycle is two goroutines, not one<a href="https://kartikrocks.github.io/wshub/blog/production-grade-websocket-server-in-go#1-the-connection-lifecycle-is-two-goroutines-not-one" class="hash-link" aria-label="Direct link to 1. The connection lifecycle is two goroutines, not one" title="Direct link to 1. The connection lifecycle is two goroutines, not one" translate="no">​</a></h2>
<p>A <code>net.Conn</code> is full-duplex, but <code>gorilla/websocket</code>'s <code>*Conn</code> is not
safe for concurrent writes from multiple goroutines — only one writer at a
time, ever. That single constraint decides the shape of every connection you
manage:</p>
<ul>
<li class=""><strong>One <code>readPump</code> goroutine</strong> blocks on <code>conn.ReadMessage()</code> and feeds
inbound frames to your handler.</li>
<li class=""><strong>One <code>writePump</code> goroutine</strong> owns the connection exclusively for writes,
draining a per-client buffered channel (<code>chan []byte</code>) and calling
<code>conn.WriteMessage</code> itself.</li>
</ul>
<p>Every other part of the system — your HTTP handlers, your business logic,
another goroutine reacting to a Redis message — writes to that channel and
never touches the socket directly. That single rule (only the writePump
writes) is what makes the rest of this list tractable. Skip it and you'll
eventually get a data race the race detector won't catch in dev, because it
only reproduces under concurrent load.</p>
<p>The cost: three long-lived goroutines per connection (readPump, writePump,
plus whatever handles the HTTP upgrade), each with its own stack. At 10,000
connections that's the dominant driver of memory per connection — roughly
25–27 KB in practice, most of it goroutine stacks and channel buffers, not the
socket itself.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="2-concurrent-access-to-the-client-map">2. Concurrent access to the client map<a href="https://kartikrocks.github.io/wshub/blog/production-grade-websocket-server-in-go#2-concurrent-access-to-the-client-map" class="hash-link" aria-label="Direct link to 2. Concurrent access to the client map" title="Direct link to 2. Concurrent access to the client map" translate="no">​</a></h2>
<p>The hub needs a <code>map[string]*Client</code> for O(1) lookup and a way to iterate it
for broadcast — and both happen from different goroutines constantly.
<code>sync.RWMutex</code> is the obvious answer, but the naive version has a trap:</p>
<div class="language-go codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-go codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token comment" style="color:#999988;font-style:italic">// Don't do this — the lock is held for the entire broadcast</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">func</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">h </span><span class="token operator" style="color:#393A34">*</span><span class="token plain">Hub</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">Broadcast</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">data </span><span class="token punctuation" style="color:#393A34">[</span><span class="token punctuation" style="color:#393A34">]</span><span class="token builtin">byte</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    h</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">mu</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">RLock</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">defer</span><span class="token plain"> h</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">mu</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">RUnlock</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">for</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">_</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> c </span><span class="token operator" style="color:#393A34">:=</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">range</span><span class="token plain"> h</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">clients </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        c</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">send </span><span class="token operator" style="color:#393A34">&lt;-</span><span class="token plain"> data </span><span class="token comment" style="color:#999988;font-style:italic">// blocks here if a client's channel is full</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<p>If one client's <code>send</code> channel is full, this blocks the whole broadcast — and
since it's holding an <code>RLock</code>, it blocks every <em>other</em> goroutine trying to
register or deregister a client, too. One slow client now stalls the entire
hub. The fix is to take a <strong>snapshot</strong> of the client slice under the lock,
then release the lock before touching any channel:</p>
<div class="language-go codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-go codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">func</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">h </span><span class="token operator" style="color:#393A34">*</span><span class="token plain">Hub</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">Broadcast</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">data </span><span class="token punctuation" style="color:#393A34">[</span><span class="token punctuation" style="color:#393A34">]</span><span class="token builtin">byte</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    h</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">mu</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">RLock</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    snapshot </span><span class="token operator" style="color:#393A34">:=</span><span class="token plain"> h</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">clientSnapshot</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token comment" style="color:#999988;font-style:italic">// copy the slice, still under RLock</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    h</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">mu</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">RUnlock</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">for</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">_</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> c </span><span class="token operator" style="color:#393A34">:=</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">range</span><span class="token plain"> snapshot </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        c</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">trySend</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">data</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token comment" style="color:#999988;font-style:italic">// non-blocking; see backpressure below</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<p>This is the difference between a broadcast that's O(1) allocations and lock
contention that scales with client count, versus one that's actually
lock-free for the read path. Measured with mock clients: broadcasting to
100,000 clients takes ~22.6 ms with zero allocations; to 1,000,000 clients,
~269 ms, still zero allocations. Targeted sends (<code>SendToClient</code>) are O(1)
regardless of hub size — ~105 ns whether there are 100K or 1M clients on the
hub, because they go straight through the map, never through the snapshot.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="3-broadcast-is-not-one-primitive--its-four">3. Broadcast is not one primitive — it's four<a href="https://kartikrocks.github.io/wshub/blog/production-grade-websocket-server-in-go#3-broadcast-is-not-one-primitive--its-four" class="hash-link" aria-label="Direct link to 3. Broadcast is not one primitive — it's four" title="Direct link to 3. Broadcast is not one primitive — it's four" translate="no">​</a></h2>
<p>"Send this to everyone" is the easy case. Production traffic needs:</p>
<ul>
<li class=""><strong>Broadcast</strong> — everyone.</li>
<li class=""><strong>Broadcast to a room</strong> — a scoped subset (a chat channel, a game
instance, a document's collaborators).</li>
<li class=""><strong>Broadcast except</strong> — everyone but the sender, so you don't echo a user's
own message back to them.</li>
<li class=""><strong>Send to user</strong> — not a connection, a <em>user</em>, who may have three tabs
open and three separate <code>*Client</code>s. This needs its own index
(<code>map[userID][]*Client</code>) alongside the connection map, because a user
reconnecting on a fourth device shouldn't require touching every existing
connection's metadata.</li>
</ul>
<p>Each of these has to preserve the same non-blocking guarantee as plain
broadcast. <code>BroadcastExcept</code> in particular is tempting to implement as
"broadcast, then skip in the loop" — which is exactly what you want, as long
as the exclusion check doesn't force an allocation per call. Done right, it
costs one allocation per call (for the exclusion set), not per client.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="4-backpressure-the-problem-nobodys-demo-has">4. Backpressure: the problem nobody's demo has<a href="https://kartikrocks.github.io/wshub/blog/production-grade-websocket-server-in-go#4-backpressure-the-problem-nobodys-demo-has" class="hash-link" aria-label="Direct link to 4. Backpressure: the problem nobody's demo has" title="Direct link to 4. Backpressure: the problem nobody's demo has" translate="no">​</a></h2>
<p>Here's the scenario every WebSocket server eventually hits: one client is on
a flaky mobile connection, or its own event loop is blocked, or it just
stopped reading. The server keeps trying to write. What happens?</p>
<p>If you write directly to the socket, that goroutine now blocks on a
platform TCP buffer that never drains, and depending on how you built the
send path, this can propagate all the way back to the broadcast loop — the
"one slow client stalls everyone" bug again, this time in the OS, not your
mutex.</p>
<p>The mitigation is a <strong>bounded per-client channel with an explicit drop
policy</strong> when it's full:</p>
<div class="language-go codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-go codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">func</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">c </span><span class="token operator" style="color:#393A34">*</span><span class="token plain">Client</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">trySend</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">data </span><span class="token punctuation" style="color:#393A34">[</span><span class="token punctuation" style="color:#393A34">]</span><span class="token builtin">byte</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">select</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">case</span><span class="token plain"> c</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">send </span><span class="token operator" style="color:#393A34">&lt;-</span><span class="token plain"> data</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">default</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token comment" style="color:#999988;font-style:italic">// buffer full — apply policy instead of blocking</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        c</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">applyDropPolicy</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">data</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<p>There are two reasonable policies, and the right one depends on what the
message <em>means</em>:</p>
<ul>
<li class=""><strong>Drop newest</strong> (default) — discard the message that just arrived. Correct
for append-only streams like chat, where losing the latest of several
queued messages is recoverable.</li>
<li class=""><strong>Drop oldest</strong> — evict the head of the queue to make room. Correct for
state-replacement streams — a live cursor position, a stock ticker, a
presence indicator — where only the <em>latest</em> value matters and an old one
is actively wrong to deliver.</li>
</ul>
<p>Either way, silently dropping data is a bug waiting to be discovered in
production unless it's observable. A drop hook —
<code>OnSendDropped(client, data)</code> — that increments a metric or logs the client
ID is not optional; it's the difference between "we tuned the buffer size
after seeing drop-rate alerts" and "we found out during a user complaint
three weeks later."</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="5-graceful-shutdown-is-two-phases-not-one">5. Graceful shutdown is two phases, not one<a href="https://kartikrocks.github.io/wshub/blog/production-grade-websocket-server-in-go#5-graceful-shutdown-is-two-phases-not-one" class="hash-link" aria-label="Direct link to 5. Graceful shutdown is two phases, not one" title="Direct link to 5. Graceful shutdown is two phases, not one" translate="no">​</a></h2>
<p><code>os.Signal</code> handling for <code>SIGTERM</code> is the easy half. The question that
matters is: <strong>what happens to the 8,000 connections currently open when the
pod gets killed for a routine deploy?</strong></p>
<p>Closing them all immediately is a correctness bug disguised as a shutdown
routine — every one of those clients gets a hard disconnect and has to
reconnect, all at once, which is its own thundering-herd problem against
whatever server picks them up next.</p>
<p>The pattern that actually works is two distinct phases:</p>
<div class="language-go codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-go codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">ctx</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> cancel </span><span class="token operator" style="color:#393A34">:=</span><span class="token plain"> context</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">WithTimeout</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">context</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">Background</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">30</span><span class="token operator" style="color:#393A34">*</span><span class="token plain">time</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">Second</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">defer</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">cancel</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">hub</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">Drain</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">ctx</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain">    </span><span class="token comment" style="color:#999988;font-style:italic">// phase 1: stop accepting new connections (HTTP 503),</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                  </span><span class="token comment" style="color:#999988;font-style:italic">// let existing ones finish naturally</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">hub</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">Shutdown</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">ctx</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token comment" style="color:#999988;font-style:italic">// phase 2: force-close anything still open</span><br></div></code></pre></div></div>
<p>During drain, new upgrade attempts get rejected, but existing connections
keep working normally — they finish their in-flight request/response cycles
and disconnect on their own terms. Connections that go idle during this
window (no traffic for the drain timeout, default 30s) get proactively
closed with a proper <code>CloseGoingAway</code> (1001) frame rather than left hanging
until the hard deadline. Only what's left after that gets force-closed in
phase two. This is what makes a rolling deploy invisible to end users instead
of a visible blip every release.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="6-rate-limiting-protects-the-hub-from-its-own-clients">6. Rate limiting protects the hub from its own clients<a href="https://kartikrocks.github.io/wshub/blog/production-grade-websocket-server-in-go#6-rate-limiting-protects-the-hub-from-its-own-clients" class="hash-link" aria-label="Direct link to 6. Rate limiting protects the hub from its own clients" title="Direct link to 6. Rate limiting protects the hub from its own clients" translate="no">​</a></h2>
<p>Connection-level backpressure protects against slow <em>readers</em>. It does
nothing against a client — malicious or just buggy — that opens connections
faster than you can process them, or floods messages at a rate no downstream
consumer can absorb. That needs limits enforced <em>before</em> the expensive part
of the pipeline runs:</p>
<ul>
<li class=""><strong>Max total connections</strong> — a hard ceiling on hub size, so a runaway client
can't exhaust file descriptors for everyone else.</li>
<li class=""><strong>Max connections per user</strong> — bounds the blast radius of one compromised
or misbehaving account, and catches the "client library retries without
backoff" bug before it becomes an incident.</li>
<li class=""><strong>Max rooms per client</strong> — the room-membership equivalent; without it, one
client subscribing to every room turns every room broadcast into a
full broadcast.</li>
</ul>
<p>The critical detail is <em>when</em> the user-ID limit gets checked. If a client can
exist unauthenticated and get a user ID assigned afterward, there's a window
where <code>MaxConnectionsPerUser</code> simply doesn't apply to it. Assigning the user
ID atomically as part of the upgrade — not as a second step after the
connection is already registered — closes that window.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="7-you-cant-operate-what-you-cant-see">7. You can't operate what you can't see<a href="https://kartikrocks.github.io/wshub/blog/production-grade-websocket-server-in-go#7-you-cant-operate-what-you-cant-see" class="hash-link" aria-label="Direct link to 7. You can't operate what you can't see" title="Direct link to 7. You can't operate what you can't see" translate="no">​</a></h2>
<p>Every mechanism above — backpressure drops, drain progress, rate-limit
rejections — is invisible unless something is counting it. A production
WebSocket service needs, at minimum:</p>
<ul>
<li class="">A <strong>metrics interface</strong> your monitoring stack can plug into (connection
count, messages sent/dropped, handler latency), kept as an interface
rather than a hard Prometheus dependency, so libraries that don't want
Prometheus in their dependency tree aren't forced to have it.</li>
<li class=""><strong><code>/healthz</code> and <code>/readyz</code></strong> as distinct signals. Liveness answers "is the
process alive"; readiness answers "should the load balancer route new
connections here" — and during a drain, those two answers correctly
diverge: alive, but not ready.</li>
<li class=""><strong>Lifecycle hooks</strong> (<code>OnConnect</code>, <code>OnDisconnect</code>, <code>OnSendDropped</code>,
<code>BeforeDisconnect</code>) as the seam where you attach logging, auditing, or
cleanup, without reaching into the hub's internals to do it.</li>
</ul>
<p>None of this is glamorous, and all of it is what turns a 3 a.m. page into a
five-minute diagnosis instead of a two-hour one.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="8-one-process-is-not-enough--and-pubsub-isnt-optional">8. One process is not enough — and pub/sub isn't optional<a href="https://kartikrocks.github.io/wshub/blog/production-grade-websocket-server-in-go#8-one-process-is-not-enough--and-pubsub-isnt-optional" class="hash-link" aria-label="Direct link to 8. One process is not enough — and pub/sub isn't optional" title="Direct link to 8. One process is not enough — and pub/sub isn't optional" translate="no">​</a></h2>
<p>Eventually one machine's connection count or CPU is the ceiling, and you need
a second node. This is where a WebSocket server's statefulness bites: unlike
a stateless HTTP API, <code>SendToUser("alice", ...)</code> only works if <em>this</em> process
holds Alice's connection. If your load balancer routed her to node B and the
message originated on node A, a naive multi-node deployment silently drops
it.</p>
<p>The fix is a message bus between nodes — Redis pub/sub or NATS are the
common choices — behind a small adapter interface:</p>
<div class="language-go codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-go codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">type</span><span class="token plain"> Adapter </span><span class="token keyword" style="color:#00009f">interface</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token function" style="color:#d73a49">Publish</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">ctx context</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">Context</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> msg AdapterMessage</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token builtin">error</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token function" style="color:#d73a49">Subscribe</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">ctx context</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">Context</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> handler </span><span class="token keyword" style="color:#00009f">func</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">AdapterMessage</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token builtin">error</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token function" style="color:#d73a49">Close</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token builtin">error</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<p>Every broadcast and targeted send publishes to the bus in addition to
delivering locally, and every node subscribes and re-delivers to whichever of
<em>its own</em> local connections match the target. Two details make this correct
rather than merely functional:</p>
<ul>
<li class=""><strong>Node-ID deduplication.</strong> Without tagging each published message with its
originating node, a broadcast fans out once locally and once again when the
node receives its own message back off the bus.</li>
<li class=""><strong>Local delivery must never block on the adapter.</strong> If Redis is slow or
down, clients on a healthy node should keep working. The adapter publish
path degrades independently from the local dispatch path — an adapter
outage should never become a local outage.</li>
</ul>
<p>If you also need cluster-wide counts (<code>GlobalClientCount</code>, <code>GlobalRoomCount</code>)
rather than per-node numbers, that's a separate periodic presence-gossip
mechanism, not something adapters give you for free — and it's worth keeping
optional, since not every deployment needs it and gossip has its own cost.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-this-adds-up-to">What this adds up to<a href="https://kartikrocks.github.io/wshub/blog/production-grade-websocket-server-in-go#what-this-adds-up-to" class="hash-link" aria-label="Direct link to What this adds up to" title="Direct link to What this adds up to" translate="no">​</a></h2>
<p>None of these eight problems is individually hard. What makes a WebSocket
server "production-grade" is that all eight have to be solved <em>together</em>, and
several of them interact — backpressure policy changes what a slow client
during drain looks like; rate limiting has to compose with the upgrade path's
atomicity; the adapter layer has to respect the same non-blocking guarantee
as local broadcast. Solve them one at a time in isolation and you'll find the
seams later, under load, at the worst time.</p>
<p>I built these ideas into an open-source Go package called
<a href="https://github.com/KARTIKrocks/wshub" target="_blank" rel="noopener noreferrer" class=""><strong>wshub</strong></a> — rooms, backpressure with
configurable drop policies, two-phase drain/shutdown, connection and rate
limits, a pluggable metrics interface with an official Prometheus
subpackage, and Redis/NATS adapters for horizontal scaling, all benchmarked
and MIT-licensed. It's built on top of <code>gorilla/websocket</code>, not a replacement
for it — the protocol handling was never the hard part.</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">go get github.com/KARTIKrocks/wshub</span><br></div></code></pre></div></div>
<p>If you're currently maintaining a hand-rolled <code>Hub struct</code> with a
<code>map[*Client]bool</code> and a <code>broadcast chan []byte</code>, most of what's above is
probably already on your TODO list. The
<a href="https://kartikrocks.github.io/wshub/" target="_blank" rel="noopener noreferrer" class="">documentation</a> has the full API, and
the <a href="https://github.com/KARTIKrocks/wshub#benchmarks" target="_blank" rel="noopener noreferrer" class="">README's benchmark section</a>
has the methodology behind every number quoted here.</p>]]></content>
        <author>
            <name>Kartik</name>
            <uri>https://github.com/KARTIKrocks</uri>
        </author>
        <category label="go" term="go"/>
        <category label="websocket" term="websocket"/>
        <category label="backend" term="backend"/>
        <category label="distributed-systems" term="distributed-systems"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[Scaling WebSockets Across Multiple Go Servers with Redis]]></title>
        <id>https://kartikrocks.github.io/wshub/blog/scaling-websockets-across-multiple-go-servers-with-redis</id>
        <link href="https://kartikrocks.github.io/wshub/blog/scaling-websockets-across-multiple-go-servers-with-redis"/>
        <updated>2026-09-11T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[A stateless HTTP API scales by adding replicas behind a load balancer. A WebSocket server doesn't, because the connection itself is the state — and the fix isn't "add Redis," it's a specific set of correctness rules for using it.]]></summary>
        <content type="html"><![CDATA[<p>A stateless HTTP handler scales the boring way: put a load balancer in
front, add replicas, done — any instance can answer any request because no
instance holds anything the others need. A WebSocket server breaks that
model by definition. The connection <em>is</em> the state. If Alice is connected to
node B and your billing webhook fires on node A and calls
<code>hub.SendToUser("alice", ...)</code>, node A's hub has no idea Alice exists. The
message doesn't error, doesn't queue, doesn't retry — it just isn't
delivered, because from node A's point of view there's no client called
Alice.</p>
<!-- -->
<p>This is article four in a series on production-grade WebSocket servers in
Go — see <a class="" href="https://kartikrocks.github.io/wshub/blog/production-grade-websocket-server-in-go">article one</a> for
the full list of problems, and <a class="" href="https://kartikrocks.github.io/wshub/blog/handling-10000-websocket-connections-in-go">article two</a>
and <a class="" href="https://kartikrocks.github.io/wshub/blog/building-backpressure-into-a-go-websocket-server">article three</a>
for connection-scaling and backpressure. This one is about the specific
correctness rules a message bus has to satisfy before "just add Redis" is
actually a fix rather than a new set of bugs — using <a href="https://github.com/KARTIKrocks/wshub" target="_blank" rel="noopener noreferrer" class="">wshub</a>'s
own Redis adapter as the worked example.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-interface-has-to-be-this-small">The interface has to be this small<a href="https://kartikrocks.github.io/wshub/blog/scaling-websockets-across-multiple-go-servers-with-redis#the-interface-has-to-be-this-small" class="hash-link" aria-label="Direct link to The interface has to be this small" title="Direct link to The interface has to be this small" translate="no">​</a></h2>
<p>Scaling out means every broadcast, every targeted send, needs a second path
alongside local delivery: publish it somewhere every other node is
listening. The interface for that "somewhere" is deliberately three methods:</p>
<div class="language-go codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-go codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">type</span><span class="token plain"> Adapter </span><span class="token keyword" style="color:#00009f">interface</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token function" style="color:#d73a49">Publish</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">ctx context</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">Context</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> msg AdapterMessage</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token builtin">error</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token function" style="color:#d73a49">Subscribe</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">ctx context</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">Context</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> handler </span><span class="token keyword" style="color:#00009f">func</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">AdapterMessage</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token builtin">error</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token function" style="color:#d73a49">Close</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token builtin">error</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<p>Small enough that Redis Pub/Sub, NATS, or a hand-rolled bus are all
one afternoon's implementation — the interface makes no assumption about
delivery guarantees, ordering, or persistence beyond "fire this at every
other node." That minimalism is deliberate: the correctness rules below live
in <em>how the hub uses</em> this interface, not in the interface itself, so any
transport that satisfies these three methods inherits them for free.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="rule-1-local-delivery-never-waits-on-the-bus">Rule 1: local delivery never waits on the bus<a href="https://kartikrocks.github.io/wshub/blog/scaling-websockets-across-multiple-go-servers-with-redis#rule-1-local-delivery-never-waits-on-the-bus" class="hash-link" aria-label="Direct link to Rule 1: local delivery never waits on the bus" title="Direct link to Rule 1: local delivery never waits on the bus" translate="no">​</a></h2>
<p>Every broadcast method does two things, in this order:</p>
<div class="language-go codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-go codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">func</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">h </span><span class="token operator" style="color:#393A34">*</span><span class="token plain">Hub</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">Broadcast</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">data </span><span class="token punctuation" style="color:#393A34">[</span><span class="token punctuation" style="color:#393A34">]</span><span class="token builtin">byte</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    h</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">broadcast</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">sendItem</span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain">msgType</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> websocket</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">TextMessage</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> data</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> data</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    h</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">publishToAdapter</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">AdapterMessage</span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        Type</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">    AdapterBroadcast</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        MsgType</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> websocket</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">TextMessage</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        Data</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain">    data</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<p>Local delivery happens first, synchronously, and unconditionally.
Publishing to the adapter happens second, and a failure there is logged and
counted — never propagated back to break local delivery:</p>
<div class="language-go codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-go codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">func</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">h </span><span class="token operator" style="color:#393A34">*</span><span class="token plain">Hub</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">publishToAdapter</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">msg AdapterMessage</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> h</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">adapter </span><span class="token operator" style="color:#393A34">==</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">nil</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    msg</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">NodeID </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> h</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">nodeID</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> err </span><span class="token operator" style="color:#393A34">:=</span><span class="token plain"> h</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">adapter</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">Publish</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">h</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">ctx</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> msg</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> err </span><span class="token operator" style="color:#393A34">!=</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">nil</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        h</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">logger</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">Error</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"adapter publish failed"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"error"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> err</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"type"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> msg</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">Type</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        h</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">metrics</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">IncrementErrors</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"adapter_publish"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<p>This ordering is the whole point. If Redis is down, degraded, or just slow,
every node's <em>local</em> clients keep working normally — they simply stop
receiving updates that originated on other nodes, which is a partial,
recoverable degradation instead of every WebSocket connection in the fleet
going down because the message bus had a bad five minutes. A message bus
that can take down local delivery when it fails isn't actually decoupled
from it.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="rule-2-every-node-has-to-ignore-its-own-echo">Rule 2: every node has to ignore its own echo<a href="https://kartikrocks.github.io/wshub/blog/scaling-websockets-across-multiple-go-servers-with-redis#rule-2-every-node-has-to-ignore-its-own-echo" class="hash-link" aria-label="Direct link to Rule 2: every node has to ignore its own echo" title="Direct link to Rule 2: every node has to ignore its own echo" translate="no">​</a></h2>
<p>Here's the bug you'd write without thinking about it twice: Node A
broadcasts. Node A delivers locally <em>and</em> publishes to Redis. Every node
subscribed to that Redis channel — including Node A itself — receives the
message back. Without a check, Node A now delivers the same broadcast to
its own clients twice.</p>
<p>The fix is a single equality check on receive:</p>
<div class="language-go codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-go codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">func</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">h </span><span class="token operator" style="color:#393A34">*</span><span class="token plain">Hub</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">handleAdapterMessage</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">msg AdapterMessage</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token comment" style="color:#999988;font-style:italic">// Ignore messages originating from this node.</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> msg</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">NodeID </span><span class="token operator" style="color:#393A34">==</span><span class="token plain"> h</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">nodeID </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">return</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token comment" style="color:#999988;font-style:italic">// ... dispatch locally, and never re-publish.</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<p><code>msg.NodeID</code> is stamped by the <em>publishing</em> node in <code>publishToAdapter</code>
above, not read back from the transport — Redis Pub/Sub doesn't tell you who
published a message, so the dedup key has to travel inside the payload. The
second half of that comment — "never re-publish" — matters just as much as
the dedup check: <code>handleAdapterMessage</code> calls the same local-delivery
functions the direct API does (<code>broadcast</code>, <code>sendToUserLocal</code>, and so on),
never <code>publishToAdapter</code>. Skip that distinction and a two-node cluster
turns into an infinite relay: A publishes, B receives and re-publishes, A
receives B's re-publish and re-publishes again, forever.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="rule-3-closing-the-subscription-correctly-is-harder-than-opening-it">Rule 3: closing the subscription correctly is harder than opening it<a href="https://kartikrocks.github.io/wshub/blog/scaling-websockets-across-multiple-go-servers-with-redis#rule-3-closing-the-subscription-correctly-is-harder-than-opening-it" class="hash-link" aria-label="Direct link to Rule 3: closing the subscription correctly is harder than opening it" title="Direct link to Rule 3: closing the subscription correctly is harder than opening it" translate="no">​</a></h2>
<p>The Redis adapter's <code>Subscribe</code> spawns two goroutines, not one, and the
reason is specific to how <code>go-redis</code>'s <code>PubSub</code> type manages its own
lifetime:</p>
<div class="language-go codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-go codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token comment" style="color:#999988;font-style:italic">// Closing the PubSub is what closes the channel the receive loop ranges</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic">// over, so it has to happen from outside that loop — a `defer sub.Close()`</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic">// on the receive goroutine can only run once the loop has already exited,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic">// which never happens. go-redis does not tie the PubSub's lifetime to the</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic">// context passed to Subscribe, so this watcher is also what makes context</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic">// cancellation stop delivery.</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">go</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">func</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">defer</span><span class="token plain"> a</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">wg</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">Done</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token operator" style="color:#393A34">&lt;-</span><span class="token plain">ctx</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">Done</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token boolean" style="color:#36acaa">_</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> sub</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">Close</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">go</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">func</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">defer</span><span class="token plain"> a</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">wg</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">Done</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    ch </span><span class="token operator" style="color:#393A34">:=</span><span class="token plain"> sub</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">Channel</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">for</span><span class="token plain"> msg </span><span class="token operator" style="color:#393A34">:=</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">range</span><span class="token plain"> ch </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token comment" style="color:#999988;font-style:italic">// ...deserialize and dispatch</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<p>The receive loop's exit condition is "the channel closes," and the channel
only closes when something calls <code>sub.Close()</code> — so a naive single-goroutine
implementation that tries <code>defer sub.Close()</code> around the <code>for range</code> loop
deadlocks forever, because the deferred call can't run until the loop it's
supposed to unblock has already exited. A second, dedicated goroutine whose
only job is watching the context and closing the subscription from outside
the loop is what actually makes cancellation work.</p>
<p>There's a second, quieter race in the same function, in how the two
goroutines get counted:</p>
<div class="language-go codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-go codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">a</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">mu</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">Lock</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic">// ...</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">a</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">wg</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">Add</span><span class="token punctuation" style="color:#393A34">(</span><span class="token number" style="color:#36acaa">2</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">a</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">mu</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">Unlock</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token comment" style="color:#999988;font-style:italic">// ... (goroutines started here, after the lock is released)</span><br></div></code></pre></div></div>
<p><code>wg.Add(2)</code> happens <em>before</em> the lock is released and before either
goroutine actually starts — not after. Go's own <code>sync.WaitGroup</code>
documentation calls this out directly: calls to <code>Add</code> that increment the
counter from zero must happen before a <code>Wait</code> that could observe it. If
<code>Add</code> happened after starting the goroutines instead, <code>Close</code> could call
<code>wg.Wait()</code> in the gap between the goroutines being scheduled and the
<code>Add</code> call actually running, see a zero counter, and return immediately —
reporting the adapter closed while both goroutines are still mid-startup.
Reserving both slots synchronously, before releasing the lock, closes that
window entirely.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-scaling-actually-looks-like-end-to-end">What "scaling" actually looks like end to end<a href="https://kartikrocks.github.io/wshub/blog/scaling-websockets-across-multiple-go-servers-with-redis#what-scaling-actually-looks-like-end-to-end" class="hash-link" aria-label="Direct link to What &quot;scaling&quot; actually looks like end to end" title="Direct link to What &quot;scaling&quot; actually looks like end to end" translate="no">​</a></h2>
<p>Put together, two <code>wshub</code> processes sharing one Redis instance behave like
this: a client connects to whichever node its load balancer routes it to;
<code>WithAdapter(redisAdapter)</code> and a stable <code>WithNodeID</code> are the only
configuration difference from a single-node setup; every broadcast and
targeted send reaches every connected client on both nodes, with each
message delivered exactly once per client no matter which node it landed
on. wshub ships this as a runnable example, not just a diagram —
<a href="https://github.com/KARTIKrocks/wshub/tree/main/examples/multinode" target="_blank" rel="noopener noreferrer" class=""><code>examples/multinode</code></a>
starts two nodes against one Redis container and exposes <code>/stats</code> on each,
printing both <code>local clients</code> (<code>hub.ClientCount()</code>) and <code>global clients</code>
(<code>hub.GlobalClientCount()</code>) side by side, so the split is visible instead of
asserted.</p>
<p>That <code>GlobalClientCount</code> number comes from a separate mechanism —
presence gossip, opt-in via <code>WithPresence(interval)</code> — not from the adapter
itself. Every tick, each node publishes its own local client and room
counts as an <code>AdapterPresence</code> message; every other node caches the latest
report per node ID and evicts it if the reporting node goes quiet for three
missed ticks (<code>presenceTTL</code> defaults to <code>3 × interval</code> — <code>WithPresence(5 * time.Second)</code> means a node that stops reporting for 15 seconds silently
drops out of everyone else's global count). This is intentionally decoupled
from message delivery: you can run multi-node broadcast without presence at
all, and add cluster-wide counts later without touching how messages move.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="try-it">Try it<a href="https://kartikrocks.github.io/wshub/blog/scaling-websockets-across-multiple-go-servers-with-redis#try-it" class="hash-link" aria-label="Direct link to Try it" title="Direct link to Try it" translate="no">​</a></h2>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">go get github.com/KARTIKrocks/wshub/adapter/redis</span><br></div></code></pre></div></div>
<div class="language-go codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-go codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">rdb </span><span class="token operator" style="color:#393A34">:=</span><span class="token plain"> goredis</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">NewClient</span><span class="token punctuation" style="color:#393A34">(</span><span class="token operator" style="color:#393A34">&amp;</span><span class="token plain">goredis</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">Options</span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain">Addr</span><span class="token punctuation" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"localhost:6379"</span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">adapter </span><span class="token operator" style="color:#393A34">:=</span><span class="token plain"> redis</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">New</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">rdb</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">hub </span><span class="token operator" style="color:#393A34">:=</span><span class="token plain"> wshub</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">NewHub</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    wshub</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">WithAdapter</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">adapter</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    wshub</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">WithNodeID</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"node-a"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    wshub</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">WithPresence</span><span class="token punctuation" style="color:#393A34">(</span><span class="token number" style="color:#36acaa">5</span><span class="token operator" style="color:#393A34">*</span><span class="token plain">time</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">Second</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">go</span><span class="token plain"> hub</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">Run</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<p>Run the two-node demo directly:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token builtin class-name">cd</span><span class="token plain"> examples/multinode </span><span class="token operator" style="color:#393A34">&amp;&amp;</span><span class="token plain"> go run </span><span class="token builtin class-name">.</span><br></div></code></pre></div></div>
<p>The <a href="https://kartikrocks.github.io/wshub/docs/adapters" target="_blank" rel="noopener noreferrer" class="">Adapters guide</a>
covers the full interface and the NATS adapter for anyone not already
running Redis; the <a href="https://kartikrocks.github.io/wshub/docs/presence" target="_blank" rel="noopener noreferrer" class="">Presence guide</a>
covers the gossip mechanism in more depth. Next in this series: what it
takes to take one of these nodes <em>out</em> of the cluster without dropping a
single in-flight connection.</p>]]></content>
        <author>
            <name>Kartik</name>
            <uri>https://github.com/KARTIKrocks</uri>
        </author>
        <category label="go" term="go"/>
        <category label="websocket" term="websocket"/>
        <category label="redis" term="redis"/>
        <category label="distributed-systems" term="distributed-systems"/>
    </entry>
</feed>