<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: CARL Bouvet</title>
    <description>The latest articles on DEV Community by CARL Bouvet (@carlbouvet).</description>
    <link>https://dev.to/carlbouvet</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4103399%2F174b19a3-be9d-4003-9b54-ca816f9344e9.jpg</url>
      <title>DEV Community: CARL Bouvet</title>
      <link>https://dev.to/carlbouvet</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/carlbouvet"/>
    <language>en</language>
    <item>
      <title>My WebSocket was open the whole time. It just stopped sending anything.</title>
      <dc:creator>CARL Bouvet</dc:creator>
      <pubDate>Mon, 31 Aug 2026 22:56:43 +0000</pubDate>
      <link>https://dev.to/carlbouvet/my-websocket-was-open-the-whole-time-it-just-stopped-sending-anything-140e</link>
      <guid>https://dev.to/carlbouvet/my-websocket-was-open-the-whole-time-it-just-stopped-sending-anything-140e</guid>
      <description>&lt;p&gt;I found it in the data, not in the logs. A gap of about three hours in one market's trade history, on a service that had not restarted, had not thrown, and had not logged a single error.&lt;/p&gt;

&lt;p&gt;The socket was &lt;code&gt;OPEN&lt;/code&gt; the whole time. The TCP connection was still established. No &lt;code&gt;close&lt;/code&gt; event, no &lt;code&gt;error&lt;/code&gt; event. Every reconnection strategy I had written — and I had written one, everybody has — was waiting for one of those two events, and neither was ever coming. The service believed it was ingesting a live feed. It was ingesting nothing.&lt;/p&gt;

&lt;p&gt;I doubt I am the only one running into this on a hand-rolled WebSocket setup.&lt;/p&gt;

&lt;p&gt;I keep about a dozen exchange WebSocket connections open continuously for a market-data service I maintain. This failure has cost me more than every disconnect combined, for one reason: a disconnect is loud. This one is silent, and you find it hours later, in the data, as a hole.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that makes it expensive
&lt;/h2&gt;

&lt;p&gt;Every alarm I had was watching the wrong thing.&lt;/p&gt;

&lt;p&gt;Process alive? Yes. Socket open? Yes. Memory, CPU, restarts? Normal. Health endpoint? Returning 200, because the health endpoint checked that the process could answer HTTP, which it could. The service was, by every signal I had built, in perfect health. It simply was not doing its job.&lt;/p&gt;

&lt;p&gt;It took me a while to accept what that meant: &lt;strong&gt;the connection is not broken, it is abandoned.&lt;/strong&gt; The transport has no opinion about whether the peer is still sending you anything. TCP will happily hold an idle connection open for as long as both sides keep the tuple alive. If you want an opinion about delivery, you have to form it yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  My first instinct was wrong
&lt;/h2&gt;

&lt;p&gt;My first hypothesis was that my hosting provider was throttling long-lived connections. It is a comfortable hypothesis: it explains everything and requires no work from me.&lt;/p&gt;

&lt;p&gt;Then I went looking for something to back it up and found nothing. Every serious description of VPS steal time describes involuntary oversubscription — your CPU cycles go to a noisy neighbour — and &lt;strong&gt;steal time does not sever a TCP connection&lt;/strong&gt;. I could not find a single fair-use clause or acceptable-use policy restricting long-lived WebSocket connections on a standard VPS plan. As far as I can tell, "my host is throttling my sockets" is a forum story that survives because nobody repeating it has measured anything.&lt;/p&gt;

&lt;p&gt;My second wrong instinct was to open &lt;code&gt;tcpdump&lt;/code&gt; straight away. I spent an evening in a capture and learned nothing I could not have learned in five minutes with two shell commands, because half the plausible causes are inside your own process and the cheap checks separate them immediately.&lt;/p&gt;

&lt;p&gt;Here is the order I would use now, cheapest first:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cause&lt;/th&gt;
&lt;th&gt;Mechanism&lt;/th&gt;
&lt;th&gt;Cheap check&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Middlebox idle timeout (proxy, LB, NAT)&lt;/td&gt;
&lt;td&gt;each hop runs its own timer and drops the entry with no FIN or RST — &lt;code&gt;readyState&lt;/code&gt; stays &lt;code&gt;OPEN&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;read the timeout config. An AWS load balancer defaults to 60 s, Cloudflare ~100 s, consumer routers 60–300 s. Disconnects at suspiciously regular intervals are this.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NAT &lt;code&gt;conntrack&lt;/code&gt; eviction&lt;/td&gt;
&lt;td&gt;the connection entry (the 5-tuple) disappears; server→client packets are dropped silently&lt;/td&gt;
&lt;td&gt;leave the connection idle past the shortest timeout in the chain&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Blocked event loop&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;the kernel receives and ACKs, but no &lt;code&gt;'message'&lt;/code&gt; is ever emitted — externally indistinguishable from a dead socket&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;perf_hooks.monitorEventLoopDelay({ resolution: 10 })&lt;/code&gt;, alert above p99 50 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backpressure / zero window&lt;/td&gt;
&lt;td&gt;your receive buffer fills, the kernel advertises Window=0, the sender stops&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ss -tipm&lt;/code&gt;, watch &lt;code&gt;rcv_space&lt;/code&gt; collapse; Wireshark shows &lt;code&gt;TCP ZeroWindow&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TCP keepalive miscalibrated&lt;/td&gt;
&lt;td&gt;Linux defaults &lt;code&gt;tcp_keepalive_time&lt;/code&gt; to &lt;strong&gt;7200 seconds&lt;/strong&gt; — two hours before the kernel even asks&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sysctl net.ipv4.tcp_keepalive_time&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VPS steal time&lt;/td&gt;
&lt;td&gt;slows your loop, which feeds back into the blocked-event-loop case; &lt;strong&gt;it does not cut TCP&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;vmstat 1 30&lt;/code&gt;, column &lt;code&gt;st&lt;/code&gt;; above 5 % the host is oversubscribed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three of those six are local to your own process. That is the part I got wrong for weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Just use ping/pong" — no
&lt;/h2&gt;

&lt;p&gt;The standard answer is a heartbeat: send a ping, expect a pong, kill the connection if the pong does not arrive. The &lt;a href="https://github.com/websockets/ws/blob/master/README.md" rel="noopener noreferrer"&gt;&lt;code&gt;ws&lt;/code&gt; heartbeat pattern&lt;/a&gt; is the canonical implementation and it is genuinely good. &lt;code&gt;socket.io&lt;/code&gt; does the equivalent internally.&lt;/p&gt;

&lt;p&gt;But it answers a narrower question than the one you have. &lt;strong&gt;Ping/pong proves the peer's protocol layer is responsive. It does not prove the peer is still delivering what you subscribed to.&lt;/strong&gt; A server can answer pings perfectly while the process behind the feed has stalled, and your connection will look healthy for hours.&lt;/p&gt;

&lt;p&gt;I did not expect to find this in CCXT, which is the reference library for crypto exchange connectivity and a lot more battle-tested than anything I have written. Its WebSocket client tracks exactly one liveness timestamp, &lt;code&gt;lastPong&lt;/code&gt;, and tears the connection down when &lt;code&gt;now - lastPong &amp;gt; keepAlive × maxPingPongMisses&lt;/code&gt; — defaults 30 000 ms and 2.0. Inbound application messages never update it. So a feed whose ping/pong loop stays healthy while its data stops will not trip that watchdog at all. In browser builds, where real ping frames cannot be sent, the client refreshes &lt;code&gt;lastPong&lt;/code&gt; on its own interval tick, and the zombie check has nothing left to detect.&lt;/p&gt;

&lt;p&gt;You can see it land in their issue tracker, reported repeatedly and still open:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/ccxt/ccxt/issues/22662" rel="noopener noreferrer"&gt;#22662&lt;/a&gt; — &lt;code&gt;watch_order_book&lt;/code&gt; hangs indefinitely: socket open, pings and pongs succeeding, no data, no exception raised.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ccxt/ccxt/issues/12918" rel="noopener noreferrer"&gt;#12918&lt;/a&gt; — fails silently; &lt;code&gt;onOpen&lt;/code&gt;, &lt;code&gt;onPing&lt;/code&gt; and &lt;code&gt;onPong&lt;/code&gt; all fire, nothing is delivered, and &lt;code&gt;verbose: true&lt;/code&gt; still reports the socket as alive.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ccxt/ccxt/issues/20667" rel="noopener noreferrer"&gt;#20667&lt;/a&gt; — a random subset of symbols stops delivering while the connection stays up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The maintainers' answer is consistently to wrap the call in &lt;code&gt;try/catch&lt;/code&gt; and re-enter the loop from user code. For a library that spans a hundred exchanges, that is a defensible place to draw the line. It does mean that if you use it, this part is yours to build.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I ended up writing down
&lt;/h2&gt;

&lt;p&gt;Once I accepted that the transport was never going to tell me, what was left was small, and more discipline than cleverness:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Proof of life is any accepted inbound message&lt;/strong&gt;, not only a pong. Liveness must not depend on the peer choosing to answer pings specifically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One clock per connection&lt;/strong&gt; — a single &lt;code&gt;lastSeenAt&lt;/code&gt; timestamp, set to now whenever rule 1 fires. No per-message-type counters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One staleness watchdog per connection.&lt;/strong&gt; If &lt;code&gt;now - lastSeenAt &amp;gt; staleAfterMs&lt;/code&gt;, presume the peer dead: stop the watchdog's own timer &lt;em&gt;first&lt;/em&gt;, then force the socket closed. It does nothing else. &lt;code&gt;staleAfterMs&lt;/code&gt; must be strictly greater than your ping interval, so one slow round-trip cannot cause a false kill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reconnect is owned by the &lt;code&gt;close&lt;/code&gt; handler, unconditionally&lt;/strong&gt; — whether the close came from the peer, from a network error, or from your own watchdog. The handler never asks why it fired. That is what keeps stale-kill and network-drop on one code path instead of two.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every close cancels every connection-scoped timer, idempotently.&lt;/strong&gt; The watchdog and the close handler will both try; cancelling twice must be safe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exponential backoff&lt;/strong&gt;, capped, N attempts, then a longer cooldown and a counter reset. Never stop retrying forever, never retry in a tight loop.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Rule 5 is the one that actually bit me, and it is the most boring one, which is probably why nobody writes about it.&lt;/p&gt;

&lt;p&gt;My reconnect loop leaked one interval per reconnection. In testing that is invisible — you reconnect two or three times and nothing happens. After a week of uptime the service was simply busy. Not leaking memory in any way I could point at, not erroring, just doing a little more work every day than the day before. I went through the ingestion code, then the database, then the parser. The bug was that my watchdog and my close handler each assumed the other had cleared the timer, so on the paths where both ran, one timer survived. It took me much longer to find than the silent socket itself, and the fix was one line and an idempotent guard.&lt;/p&gt;

&lt;h2&gt;
  
  
  And the exception, because there is always one
&lt;/h2&gt;

&lt;p&gt;On my heaviest feed, rule 1 is wrong.&lt;/p&gt;

&lt;p&gt;Binance sends a server ping roughly every 20 seconds whether or not the market is moving. That makes the ping a &lt;em&gt;stronger&lt;/em&gt; signal than the data: a quiet market legitimately produces no trades, but a healthy connection always produces pings. So that connector deliberately does the opposite — it keeps two timestamps, watches the &lt;strong&gt;ping&lt;/strong&gt; clock to decide whether to kill the connection, and keeps the data clock for diagnostics. Killing on data silence there would mean reconnecting every time order flow goes quiet.&lt;/p&gt;

&lt;p&gt;So the rule is not "data is liveness". It is: &lt;strong&gt;measure liveness on whatever the peer has promised to send unconditionally.&lt;/strong&gt; For most feeds that is the data, because their pings are reactive — they only answer yours. For a feed that pings on its own schedule, the ping is the better clock, and data silence becomes something you &lt;em&gt;alert&lt;/em&gt; on rather than something you &lt;em&gt;reconnect&lt;/em&gt; on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens after the reconnect
&lt;/h2&gt;

&lt;p&gt;A reconnect, on its own, loses everything that happened during the gap. Detecting the hole is not the same as filling it.&lt;/p&gt;

&lt;p&gt;What I run around it, in tiers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Short gaps, at reconnect.&lt;/strong&gt; On re-open, refetch exactly &lt;code&gt;[last stored timestamp + 1, now]&lt;/code&gt; from the REST endpoint, bounded by a threshold — mine is five minutes — past which the reconnect path gives up and defers. A timestamp boundary is simpler than a sequence-number one and, for a trade feed, sufficient. One known limit I have not fixed: a single unpaginated call caps how many events a gap may contain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Longer gaps, asynchronously.&lt;/strong&gt; A separate detector scans stored history for holes over a rolling window and refills them, then recomputes whatever was derived from the missing rows. Writes are upserts keyed on &lt;code&gt;{market, timestamp}&lt;/code&gt;, so the same gap can be filled twice without consequence. The layers overlap on purpose; idempotency is what makes that safe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Historical loads.&lt;/strong&gt; An offline importer for backfills measured in months, entirely outside the live path.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The handoff between these layers is explicit in the code rather than implied — the reconnect path logs that it is deferring, and to whom. That was the fix for a real class of bug: two layers each assuming the other had covered the gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I did not build
&lt;/h2&gt;

&lt;p&gt;Worth stating, because a post that only lists what you did is marketing. None of this exists in my stack: redundant streams with failover (Coinbase publishes two endpoints precisely for this), sequence-number gap detection — a timestamp boundary will never tell you that you lost message N+1 — proactive reconnection before a forced disconnect, jitter on the backoff, snapshot-driven state reset.&lt;/p&gt;

&lt;p&gt;If you are maintaining an order book you need all of it. Detection is the sensor that would trigger the failover; the system should survive the sensor being wrong, in both directions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The code
&lt;/h2&gt;

&lt;p&gt;I extracted the invariant from my own connectors — 13 of them across seven services, which had drifted into three different answers to the same question — and published it: &lt;a href="https://github.com/CARL-Bouvet/resilient-ws" rel="noopener noreferrer"&gt;github.com/CARL-Bouvet/resilient-ws&lt;/a&gt;. One file, 366 lines, no dependencies, MIT, four tests on injected fake timers. It runs anywhere the standard &lt;code&gt;WebSocket&lt;/code&gt; global exists.&lt;/p&gt;

&lt;p&gt;Those near-copies were never really separate implementations. They were one design decision that nobody had written down, drifting apart quietly for a year. So I wrote it down.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;English is not my native language — I write it a good deal better than I speak it. Some sentences may read oddly.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>websocket</category>
      <category>node</category>
      <category>typescript</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
