<?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: lujianfu2008</title>
    <description>The latest articles on DEV Community by lujianfu2008 (@lujianfu2008).</description>
    <link>https://dev.to/lujianfu2008</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%2F4232977%2Fe29cad2b-1b88-4836-9cad-61541254da34.png</url>
      <title>DEV Community: lujianfu2008</title>
      <link>https://dev.to/lujianfu2008</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lujianfu2008"/>
    <language>en</language>
    <item>
      <title>I Built a Fully-Local Voice AI Assistant with a 3D Avatar — Here's What I Learned</title>
      <dc:creator>lujianfu2008</dc:creator>
      <pubDate>Sun, 08 Nov 2026 02:15:20 +0000</pubDate>
      <link>https://dev.to/lujianfu2008/i-built-a-fully-local-voice-ai-assistant-with-a-3d-avatar-heres-what-i-learned-3ojj</link>
      <guid>https://dev.to/lujianfu2008/i-built-a-fully-local-voice-ai-assistant-with-a-3d-avatar-heres-what-i-learned-3ojj</guid>
      <description>&lt;p&gt;For the past few months I've been building a personal AI assistant called &lt;strong&gt;Xiaoqingting&lt;/strong&gt; ("Little Dragonfly"). Not a chat wrapper — a full desktop assistant: voice in, voice out, tools that actually touch my machine, multiple agents with different jobs, and a 3D anime avatar that talks to me.&lt;/p&gt;

&lt;p&gt;Everything important runs locally. The LLM is the only cloud component.&lt;/p&gt;

&lt;p&gt;It's open source: &lt;a href="https://github.com/lujianfu2008/xiaoqingting" rel="noopener noreferrer"&gt;github.com/lujianfu2008/xiaoqingting&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This post is the architecture tour and the honest lessons list — including the bugs that cost me full nights.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture: Two Processes and a Contract
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────┐   REST + WebSocket   ┌─────────────────┐
│  @blm/desktop   │ ◄──────────────────► │   @blm/server   │
│                 │                      │                 │
│ Electron client │                      │  Fastify + SDK  │
│ voice / chat /  │                      │  agents / auth  │
│ tools / browser │                      │  gateway / keys │
└─────────────────┘                      └─────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two packages, one monorepo:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;@blm/desktop&lt;/code&gt;&lt;/strong&gt; — the Electron client. Voice capture, conversation UI, the three-vrm avatar, and a browser extension for voice dictation on any web page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;@blm/server&lt;/code&gt;&lt;/strong&gt; — Fastify server wrapping the Claude Agent SDK. It owns agents, skills, tool approval, multi-user auth (JWT), and an LLM gateway with per-user quotas.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every message between them goes through a shared protocol package (&lt;code&gt;@blm/protocol&lt;/code&gt;) validated with Zod. If I could give past-me one piece of advice, it's this: &lt;strong&gt;define the protocol as a package on day one.&lt;/strong&gt; Every refactor since has been cheap precisely because both sides fail loudly and at compile time when the contract drifts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 1: Local-First Is an Architecture Decision, Not a Feature
&lt;/h2&gt;

&lt;p&gt;Early on I had to decide where ASR, TTS, and wake-word detection live. The lazy answer is "call a cloud API for everything." I chose local:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ASR&lt;/strong&gt;: sherpa-onnx Paraformer (streaming, int8, ~79 MB model) — fully offline, Chinese recognition works well&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TTS&lt;/strong&gt;: Windows SAPI locally, streamed in chunks so the avatar starts speaking before the full audio is ready&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wake word&lt;/strong&gt;: a small keyword-spotting model, with wake words generated at runtime from pinyin so homophones work automatically&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Was it worth the extra work? Absolutely, for three reasons: &lt;strong&gt;latency&lt;/strong&gt; (no network round-trip before the avatar starts talking), &lt;strong&gt;privacy&lt;/strong&gt; (my voice never leaves the machine), and &lt;strong&gt;cost&lt;/strong&gt; (voice calls to cloud APIs are surprisingly expensive at conversational volume).&lt;/p&gt;

&lt;p&gt;The one thing I'd redo: I underestimated model file logistics. 79 MB binaries don't belong in git, so the repo ships without the model and the user downloads it from sherpa-onnx releases. Write that setup step clearly in your README or your issues tab will fill up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 2: Multi-Agent Doesn't Need a Framework
&lt;/h2&gt;

&lt;p&gt;The assistant is actually several agents: &lt;code&gt;assistant&lt;/code&gt; (chat + voice), &lt;code&gt;coder&lt;/code&gt; (gets shell/file tools), &lt;code&gt;writer&lt;/code&gt; (long-form, no destructive tools). Different system prompts, different tool allowlists, same SDK underneath.&lt;/p&gt;

&lt;p&gt;I never needed LangGraph-style graphs for this. The whole thing is:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;agents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;assistant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;safeTools&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PERSONA&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;coder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;devTools&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;   &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CODER_PERSONA&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;readTools&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;  &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;WRITER_PERSONA&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;What actually made it feel like "multiple agents" was &lt;strong&gt;tool gating per agent&lt;/strong&gt; — the coder can run commands, the writer can't touch anything destructive. If you're building multi-agent today, spend your complexity budget on permission boundaries, not orchestration graphs.&lt;/p&gt;

&lt;p&gt;Related: the server's default posture for tools is &lt;strong&gt;deny unless allowlisted&lt;/strong&gt;, and the engine never receives secrets from the server environment. An assistant with shell access is a remote-code-execution vector wearing a cute avatar. Treat its tools like you'd treat an API's public endpoints.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 3: Long Conversations Will Stall, So Watch Them
&lt;/h2&gt;

&lt;p&gt;This one cost me a night. Occasionally an agent turn would simply produce nothing — the SDK had burned its budget and the CLI process went silent without emitting an error. The user stared at a spinner forever.&lt;/p&gt;

&lt;p&gt;The fix is a &lt;strong&gt;turn watchdog&lt;/strong&gt;: when a turn starts, set a deadline. If no message arrives by the deadline, kill the engine process hard and tell the user what actually happened ("this turn's compute budget ran out") instead of showing a zombie spinner.&lt;/p&gt;

&lt;p&gt;Two details that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The watchdog deadline does &lt;strong&gt;not&lt;/strong&gt; extend when the user impatiently clicks "are you there" — a restarted timer is a lie&lt;/li&gt;
&lt;li&gt;After aborting, force-tear down the socket after 10 seconds; graceful close can hang on a dead process&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I wrote up the other half of this reliability story — zombie WebSocket connections and heartbeat design — in my &lt;a href="https://dev.to/lujianfu2008/why-your-websocket-users-go-ghost-online-a-real-production-war-story-3c0k"&gt;previous post&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 4: The Avatar Will Betray You (three-vrm Edition)
&lt;/h2&gt;

&lt;p&gt;The 3D avatar is a three-vrm model in a WebGL scene. The trap: VRM 0 models face &lt;strong&gt;away&lt;/strong&gt; from the camera by convention, and everyone's first fix is &lt;code&gt;vrm.rotation.y = Math.PI&lt;/code&gt;. Mine promptly turned its back to the camera the first time an animation clip overrode the transform.&lt;/p&gt;

&lt;p&gt;The lesson generalizes: when a scene graph fights you, find out &lt;em&gt;who owns the transform&lt;/em&gt;. Animations own it during playback; your rotation fix must happen inside the same update loop, after the animation, not once at load time. Also:/animations for idle/breathing sell the "alive" feeling more than lip-sync quality does — get a subtle idle loop in before polishing anything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 5: Protocol Mismatches Fail Weirdly
&lt;/h2&gt;

&lt;p&gt;The LLM gateway accepts OpenAI-compatible upstreams, and the most confusing bug of the whole project looked like this: a perfectly valid request, upstream answers &lt;strong&gt;"model not found."&lt;/strong&gt; The model name was fine. The real problem: the client appended &lt;code&gt;?beta=true&lt;/code&gt; to the URL, the upstream router keyed its model registry on the full path &lt;strong&gt;including the query string&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Strip queries before routing. And if your upstream speaks a protocol that returns assistant messages as separate &lt;code&gt;text&lt;/code&gt; and &lt;code&gt;tool_use&lt;/code&gt; chunks, merge them — some routers choke on the split form. Protocol bugs don't look like protocol bugs; they look like the upstream lying to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Ship the browser extension earlier.&lt;/strong&gt; Voice-dictating into any web page turned out to be the feature I personally use most, and it was added last&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Budget display from day one.&lt;/strong&gt; Per-turn cost, per-user quota — retro-fitting usage analytics onto a system that's already live is miserable&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Record architecture decisions.&lt;/strong&gt; I have a &lt;code&gt;CLAUDE.md&lt;/code&gt; with design notes, and every time I onboard my future self to a subsystem I thank past me for it&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;The repo is here: &lt;a href="https://github.com/lujianfu2008/xiaoqingting" rel="noopener noreferrer"&gt;github.com/lujianfu2008/xiaoqingting&lt;/a&gt; — Node 20+, &lt;code&gt;npm install&lt;/code&gt;, configure &lt;code&gt;.env&lt;/code&gt;, and you have a local assistant. Star it if you like the architecture; issues and PRs welcome.&lt;/p&gt;

&lt;p&gt;Next post: the skills system — how the assistant discovers skill packs at runtime and injects them into its own system prompt. Follow me so you don't miss it.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>electron</category>
      <category>typescript</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Why Your WebSocket Users Go "Ghost Online" — A Real Production War Story</title>
      <dc:creator>lujianfu2008</dc:creator>
      <pubDate>Sun, 08 Nov 2026 01:35:48 +0000</pubDate>
      <link>https://dev.to/lujianfu2008/why-your-websocket-users-go-ghost-online-a-real-production-war-story-3c0k</link>
      <guid>https://dev.to/lujianfu2008/why-your-websocket-users-go-ghost-online-a-real-production-war-story-3c0k</guid>
      <description>&lt;p&gt;First, a true story from our production environment.&lt;/p&gt;

&lt;p&gt;Users reported that messages wouldn't send — the spinner just kept spinning. But the UI said "Connected", with a nice green dot. The server logs were even weirder: those users' connections were &lt;strong&gt;still alive&lt;/strong&gt; on the server. The server thought they were online, and was faithfully pushing messages into dead sockets.&lt;/p&gt;

&lt;p&gt;Restarting the app fixed it instantly. A few hours later, it came back.&lt;/p&gt;

&lt;p&gt;Zombie connections — also known as half-open connections. Below is everything we learned, plus the four-part reliability kit we ended up shipping. Don't ask what version one looked like. Version one was the absence of everything below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why TCP Lies to You
&lt;/h2&gt;

&lt;p&gt;WebSocket sits on top of TCP, and TCP has a property many people only learn about in production:&lt;/p&gt;

&lt;p&gt;If the peer drops off the network abruptly — cable unplugged, laptop asleep, Wi-Fi to 4G — your socket fires &lt;strong&gt;no events at all&lt;/strong&gt;. No &lt;code&gt;close&lt;/code&gt;, no &lt;code&gt;error&lt;/code&gt;, nothing.&lt;/p&gt;

&lt;p&gt;In TCP's world, you only learn a connection is dead when you actively send data and the send fails. If neither side speaks, a "connection" can stay half-dead for hours: the routers in the middle dropped the session long ago, while both endpoints still believe the other is there.&lt;/p&gt;

&lt;p&gt;So here's the iron rule: &lt;strong&gt;never rely on &lt;code&gt;onclose&lt;/code&gt; to detect a dead connection. It will not come.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 1: Bidirectional Heartbeats
&lt;/h2&gt;

&lt;p&gt;The fix is unglamorous: make both sides talk periodically, and declare death on silence.&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;// Client: ping every 30s; if no pong within 15s, kill the socket and reconnect&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;HEARTBEAT_INTERVAL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nx"&gt;_000&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;PONG_TIMEOUT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;pongTimer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;startHeartbeat&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="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &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;readyState&lt;/span&gt; &lt;span class="o"&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;OPEN&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="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="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;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ping&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;

    &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pongTimer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;pongTimer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="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="nf"&gt;reconnect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;PONG_TIMEOUT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;HEARTBEAT_INTERVAL&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Server: reply to pings, track last-seen time&lt;/span&gt;
&lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;message&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;msg&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;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&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;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&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="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="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;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pong&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
    &lt;span class="nf"&gt;markAlive&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="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Reaper: clean up connections silent for 90+ seconds&lt;/span&gt;
&lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &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;ws&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;clients&lt;/span&gt;&lt;span class="p"&gt;)&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="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;lastActive&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="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;)&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="nf"&gt;terminate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two traps here, both of which we stepped on ourselves.&lt;/p&gt;

&lt;p&gt;First: &lt;strong&gt;heartbeats must be bidirectional&lt;/strong&gt;. Our first version only had client-side pings, so the server kept pushing into dead sockets — the client zombie problem was solved, the server-side zombie queue was not. The same logic applies in reverse.&lt;/p&gt;

&lt;p&gt;Second: use &lt;code&gt;terminate()&lt;/code&gt;, not &lt;code&gt;close()&lt;/code&gt;, for server-side reaping. &lt;code&gt;close()&lt;/code&gt; runs a graceful handshake and waits for the peer to respond. The peer is already dead. Stop waiting. &lt;code&gt;terminate()&lt;/code&gt; destroys the socket immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 2: Exponential Backoff Reconnect
&lt;/h2&gt;

&lt;p&gt;Once you detect death, you reconnect. The laziest implementation is &lt;code&gt;setTimeout(reconnect, 1000)&lt;/code&gt;, which buys you two problems: when the server restarts, every client slams it at the same second (the reconnect storm), and during a long outage clients keep hammering retries every second for nothing.&lt;/p&gt;

&lt;p&gt;The standard fix is exponential backoff with jitter:&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;reconnect&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;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 1s → 2s → 4s … capped at 30s&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.7&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// ±30% jitter&lt;/span&gt;
  &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;connect&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;ws&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WebSocket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onopen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;resubscribe&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// don't skip this — next section&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 jitter line is what everyone skips. Don't. It exists precisely to prevent "all clients reconnecting in perfect unison", and it costs one line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 3: State Recovery After Reconnect
&lt;/h2&gt;

&lt;p&gt;The most commonly missed piece: a successful reconnect does not mean everything is fine.&lt;/p&gt;

&lt;p&gt;While you were offline, other people sent messages, task states changed, and your subscriptions evaporated. Do nothing after reconnecting, and your users see a mysterious "time hole" in the conversation.&lt;/p&gt;

&lt;p&gt;Our approach: reconcile after every reconnect.&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="nx"&gt;onopen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// Send the last message id we have; server replies with the delta.&lt;/span&gt;
  &lt;span class="c1"&gt;// If the gap is too large, fall back to a full refresh.&lt;/span&gt;
  &lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="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;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sync&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;lastMsgId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastMsgId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Delta catch-up with a full-refresh fallback — I haven't found a simpler scheme that actually holds up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 4: Acks and Stale-Event Guards
&lt;/h2&gt;

&lt;p&gt;Two smaller things, worth their own section.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Send acks.&lt;/strong&gt; "The client sent it" and "the server received it" are different facts. For important collaborative operations, have the server acknowledge with a message id; only then does the client mark it as delivered, and unacked messages go into a retry queue. That gray-to-blue checkmark in your UI is local state — don't let it lie to you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stale events.&lt;/strong&gt; Here's a nasty bug hiding in reconnect logic: after an old socket dies, it can still fire &lt;code&gt;onmessage&lt;/code&gt; one last time — leftovers from the kernel buffer. Handle it normally, and a message from "the previous world" pollutes your current state. This one took us a full night to track down. The fix is a generation counter, three lines:&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;generation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;connect&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;myGen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="nx"&gt;generation&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;ws&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WebSocket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onmessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;myGen&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;generation&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="c1"&gt;// event from a stale socket — drop it&lt;/span&gt;
    &lt;span class="nf"&gt;handleMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="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;This kit holds up well at a few thousand concurrent connections. Past tens of thousands, the heartbeat scan needs its own optimization pass — but that's a story for another post.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Checklist
&lt;/h2&gt;

&lt;p&gt;Audit your project against this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Client pings and &lt;strong&gt;waits for pong&lt;/strong&gt;; on timeout it declares death itself, never waiting for &lt;code&gt;onclose&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Server tracks last-active time and reaps zombies with &lt;code&gt;terminate()&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Reconnects use exponential backoff + jitter + a cap&lt;/li&gt;
&lt;li&gt;[ ] After reconnecting: delta sync, or a full reconciliation fallback&lt;/li&gt;
&lt;li&gt;[ ] Important messages have acks; unacked messages go to a retry queue&lt;/li&gt;
&lt;li&gt;[ ] Events from stale sockets are dropped via a generation guard&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next post: reliable message delivery — how a server guarantees a message is delivered &lt;em&gt;exactly once&lt;/em&gt;, and how out-of-order "paranormal events" actually happen. Follow me if that sounds like your kind of problem.&lt;/p&gt;

</description>
      <category>websocket</category>
      <category>backend</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
