<?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: Peter Li</title>
    <description>The latest articles on DEV Community by Peter Li (@petersg).</description>
    <link>https://dev.to/petersg</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%2F4081942%2Fe6ba1712-f56d-4dc0-8adf-e22ffede181a.png</url>
      <title>DEV Community: Peter Li</title>
      <link>https://dev.to/petersg</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/petersg"/>
    <language>en</language>
    <item>
      <title>Your WebSocket ping is answered by the edge, not your Durable Object</title>
      <dc:creator>Peter Li</dc:creator>
      <pubDate>Mon, 17 Aug 2026 16:12:13 +0000</pubDate>
      <link>https://dev.to/petersg/your-websocket-ping-is-answered-by-the-edge-not-your-durable-object-2hh4</link>
      <guid>https://dev.to/petersg/your-websocket-ping-is-answered-by-the-edge-not-your-durable-object-2hh4</guid>
      <description>&lt;p&gt;We run SSH traffic through a Cloudflare Durable Object. Each user's machine holds one outbound control WebSocket to the DO; when a phone wants in, the DO tells the machine to dial back. This is a standard hub pattern built on the WebSocket Hibernation API.&lt;/p&gt;

&lt;p&gt;It worked, then it started failing, and every signal we had said the system was healthy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;Phone connects to &lt;code&gt;/connect/:machineId&lt;/code&gt;. The socket opens, then immediately closes with &lt;code&gt;1013&lt;/code&gt;. Meanwhile the machine that is supposedly offline believes it is connected:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;TCP is &lt;code&gt;ESTABLISHED&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The WebSocket's protocol-level ping (&lt;code&gt;pingInterval = 15s&lt;/code&gt;) succeeds, every time&lt;/li&gt;
&lt;li&gt;The app has no error to report&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Only restarting the machine's app fixed it.&lt;/p&gt;

&lt;p&gt;There is exactly one place in our Worker that emits &lt;code&gt;1013&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1013&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;machine agent offline&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That line runs when &lt;code&gt;getWebSockets("agent")&lt;/code&gt; returns nothing for that machine. So the DO did not have the socket.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Under Hibernation, the Cloudflare runtime answers protocol-level ping/pong on your behalf, without waking the Durable Object.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is documented, intentional behavior. The entire point of Hibernation is to stop billing GB-seconds for an idle object, and waking it up to say "yes, still here" would defeat that.&lt;/p&gt;

&lt;p&gt;But it means this state is reachable:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;State&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;TCP connection&lt;/td&gt;
&lt;td&gt;alive&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebSocket protocol ping/pong&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;succeeds&lt;/strong&gt; (answered at the edge)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DO instance still holding this socket&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;lost&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The machine decided it was connected based on row 2. To deliver a dial, the DO needs row 3.&lt;/p&gt;

&lt;p&gt;This is not the TCP blackhole case, where the connection is dead and everything stalls. Here TCP is genuinely fine and the protocol ping is succeeding. The health check is correct. It is answering the wrong question.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;The fix is an application-level round trip, where the pong is produced by your own code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;webSocketMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;WebSocket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nb"&gt;ArrayBuffer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;roleOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;agent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Control frames on this socket are text. Do not hand a non-string to&lt;/span&gt;
    &lt;span class="c1"&gt;// JSON.parse. `JSON.parse(typeof msg === "string" ? msg : "")` throws&lt;/span&gt;
    &lt;span class="c1"&gt;// SyntaxError on the empty string — ask me how I know.&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;control&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;op&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;control&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;op&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ping&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// The point is that THIS code ran. That is the signal.&lt;/span&gt;
        &lt;span class="nf"&gt;safeSend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;op&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pong&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Drop malformed control frames silently. A heartbeat that becomes a new&lt;/span&gt;
      &lt;span class="c1"&gt;// reason to disconnect is worse than no heartbeat.&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forward&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The machine sends &lt;code&gt;{op:"ping"}&lt;/code&gt; every 45s and reconnects after two missed cycles (100s, so two periods plus slack).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not reach for &lt;code&gt;setWebSocketAutoResponse()&lt;/code&gt; here:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// This puts the blind spot right back.&lt;/span&gt;
&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setWebSocketAutoResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WebSocketRequestResponsePair&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ping&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pong&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That API exists specifically to answer without waking the DO, which makes it exactly wrong as a liveness probe, for the same reason protocol ping was: the reply proves the edge is up, not that your object still holds the socket.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shipping the heartbeat before the Worker
&lt;/h2&gt;

&lt;p&gt;We shipped the machine agent with the new heartbeat while the old Worker was still deployed. The old Worker ignores &lt;code&gt;{op:"ping"}&lt;/code&gt;, so no pong ever arrives. An agent that treats silence as death then severs a perfectly healthy connection every 100 seconds and keeps reconnecting until the Worker rolls out.&lt;/p&gt;

&lt;p&gt;The guard is one clause:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nf"&gt;controlHeartbeatExpired&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;pongSeen&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt; &lt;span class="n"&gt;sinceLastInbound&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;pongSeen&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;sinceLastInbound&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;kControlPingTimeout&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pongSeen &amp;amp;&amp;amp;&lt;/code&gt; reads as redundant: if it timed out, surely it is dead. Delete it and you get the reconnect storm above, but only during the window where client and server versions disagree. Staging, where you deploy both together, will not reproduce it. We pinned it with a test so nobody deletes it for looking useless.&lt;/p&gt;

&lt;p&gt;If your client and server deploy independently, decide explicitly what happens when the peer does not answer your new protocol.&lt;/p&gt;

&lt;h2&gt;
  
  
  What protocol ping is still good for
&lt;/h2&gt;

&lt;p&gt;We did not remove it. Data connections still run &lt;code&gt;pingInterval = 15s&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Mobile NAT reclaims idle mappings within tens of seconds, and after that nothing arrives and nothing errors. A terminal just sits on its last frame forever. Edge-proxied pongs detect this perfectly well, because if TCP were actually dead the proxied reply could not reach you either.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Right probe&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Is the TCP/NAT path alive?&lt;/td&gt;
&lt;td&gt;protocol ping is &lt;strong&gt;enough&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Does the DO still hold this socket?&lt;/td&gt;
&lt;td&gt;application round trip &lt;strong&gt;required&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Data connections kept &lt;code&gt;pingInterval = 15s&lt;/code&gt;. Only the control socket got the application round trip.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This came out of building &lt;a href="https://beam.otterd.com/about" rel="noopener noreferrer"&gt;Otter Beam&lt;/a&gt;, which lets you reach the tmux sessions on your own machine from a phone. The relay only forwards the encrypted SSH stream; it cannot read it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>websocket</category>
      <category>serverless</category>
      <category>debugging</category>
    </item>
  </channel>
</rss>
