<?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: Matt Fauveau</title>
    <description>The latest articles on DEV Community by Matt Fauveau (@mfauveau).</description>
    <link>https://dev.to/mfauveau</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%2F4038692%2F42fa9c46-cdf7-45be-b0e4-d0cf7a16b71f.jpg</url>
      <title>DEV Community: Matt Fauveau</title>
      <link>https://dev.to/mfauveau</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mfauveau"/>
    <language>en</language>
    <item>
      <title>Making a WebSocket survive Chrome's Manifest V3</title>
      <dc:creator>Matt Fauveau</dc:creator>
      <pubDate>Tue, 21 Jul 2026 18:30:45 +0000</pubDate>
      <link>https://dev.to/mfauveau/making-a-websocket-survive-chromes-manifest-v3-1p0h</link>
      <guid>https://dev.to/mfauveau/making-a-websocket-survive-chromes-manifest-v3-1p0h</guid>
      <description>&lt;p&gt;The bug reports all sounded the same. "It disconnected again." No error, no warning, nothing on screen to explain it. Someone would open a GitHub issue, start a planning-poker vote, then stop to actually read the thing they were estimating. Thirty seconds later they'd look back and the room had quietly gone still. Other people's votes weren't showing up, and their own weren't reaching the room. It had simply stopped, without a word.&lt;/p&gt;

&lt;p&gt;If you've ever built anything real on Chrome's Manifest V3, you can probably guess where this is going. The socket didn't drop because the network hiccupped. It dropped because Chrome reached over and killed the process it was living in.&lt;/p&gt;

&lt;p&gt;This is the story of that bug, and what it actually takes to keep a WebSocket alive inside a Manifest V3 extension.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Manifest V3 runs your background code in a service worker that Chrome terminates after ~30s idle. If your WebSocket lives there, it dies with it, silently. The fix is three parts: a heartbeat so extension activity keeps the worker awake, auto-reconnect that pulls a fresh snapshot so drops are invisible, and a try/catch around every port message because the worker can die between your null-check and the call.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why the socket doesn't live where you'd expect
&lt;/h2&gt;

&lt;p&gt;First, some context on how the extension is wired, because it explains why this bug was even possible.&lt;/p&gt;

&lt;p&gt;It puts a button on GitHub issues and boards. Click it, and a live planning-poker room opens right there in the page. Votes flow over a WebSocket to a Cloudflare &lt;a href="https://developers.cloudflare.com/durable-objects/" rel="noopener noreferrer"&gt;Durable Object&lt;/a&gt; that owns the room's state. Standard real-time stuff.&lt;/p&gt;

&lt;p&gt;The obvious place to open that socket is the content script, the code injected into the GitHub page. That's where the UI lives, so why not open the socket right next to it?&lt;/p&gt;

&lt;p&gt;Firefox is why not. A content script runs in the page's world, which means it inherits the page's Content Security Policy. GitHub ships a strict &lt;code&gt;connect-src&lt;/code&gt;, and Firefox enforces it against content scripts. Try to open &lt;code&gt;wss://…&lt;/code&gt; from there and the browser refuses. (Chrome is more lenient here, but I wanted one codebase, not two.)&lt;/p&gt;

&lt;p&gt;So the socket moved to the background. The background service worker isn't subject to any page's CSP, so it can open the WebSocket freely. The content script talks to it over a &lt;code&gt;chrome.runtime&lt;/code&gt; port, and the background relays frames both ways. Problem solved.&lt;/p&gt;

&lt;p&gt;Except moving the socket into the service worker is exactly what set up the disconnect bug. Which brings us to Manifest V3.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Manifest V3 took away
&lt;/h2&gt;

&lt;p&gt;If you built extensions in the Manifest V2 days, you had a persistent background page. It loaded when the extension started and stayed loaded. A WebSocket opened there just... stayed open. That was the whole model.&lt;/p&gt;

&lt;p&gt;Manifest V3 replaced that background page with a service worker, and service workers are built to be disposable. Chrome spins one up to handle an event, then terminates it once things go quiet, &lt;a href="https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle" rel="noopener noreferrer"&gt;after about 30 seconds of inactivity&lt;/a&gt;. The idea is efficiency: don't keep code resident that isn't doing anything.&lt;/p&gt;

&lt;p&gt;But a WebSocket &lt;em&gt;is&lt;/em&gt; doing something. It's holding a connection open. The trouble is that holding a connection open doesn't, by itself, count as activity. So Chrome looks at your idle service worker, decides it's dead weight, and shuts it down. Your socket goes with it, silently, with no close frame the user ever sees.&lt;/p&gt;

&lt;p&gt;That's the disconnect. Not a network problem. A lifecycle problem. The fix has three parts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part one: a heartbeat to stay awake
&lt;/h2&gt;

&lt;p&gt;The first job is keeping the service worker alive while a room is open. And the rule that saves you is this: &lt;strong&gt;extension activity resets the idle timer.&lt;/strong&gt; Messaging traffic counts. WebSocket traffic counts.&lt;/p&gt;

&lt;p&gt;So the extension sends a heartbeat. Every 20 seconds, the content script posts a small message over the port, the background answers by sending a &lt;code&gt;ping&lt;/code&gt; frame on the socket, and that traffic keeps the worker's idle timer from ever reaching zero.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Keep the port busy roughly twice per MV3 idle window (~30s).&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;HEARTBEAT_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="nx"&gt;_000&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="nx"&gt;heartbeat&lt;/span&gt; &lt;span class="o"&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;__ping&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="nx"&gt;HEARTBEAT_MS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty seconds, not twenty-nine, on purpose. The idle window is around 30 seconds, so you want to poke it at least twice per window and leave slack for a slow frame. Cutting it close is how you end up debugging the same disconnect all over again.&lt;/p&gt;

&lt;p&gt;Now, you might worry about the cost of pinging a server every 20 seconds for every open room. Here's the nice part. The room lives in a Durable Object that hibernates when nothing's happening, and Cloudflare lets you register an automatic response that answers a ping without ever waking it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the heartbeat keeps the extension's service worker awake, but the server answers each &lt;code&gt;ping&lt;/code&gt; with a &lt;code&gt;pong&lt;/code&gt; from the runtime itself. The Durable Object stays asleep. You get the keepalive you need without paying to wake the room thousands of times a day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part two: reconnect like nothing happened
&lt;/h2&gt;

&lt;p&gt;A heartbeat reduces disconnects. It doesn't eliminate them. Laptops sleep, networks flap, and Chrome will still occasionally win the race and tear the worker down anyway. So the second job is making any drop recover on its own.&lt;/p&gt;

&lt;p&gt;When the socket dies, the extension reconnects with exponential backoff: wait a second, try again, and double the wait each time up to a 15-second ceiling.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reconnectDelay&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="nx"&gt;reconnectDelay&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reconnectDelay&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reconnectTimer&lt;/span&gt; &lt;span class="o"&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The part that makes this feel seamless isn't the backoff, though. It's what happens on the other end. When a client connects to a room, the Durable Object's first move is to send a complete snapshot: the current ticket, every participant, who's voted, the whole state of the table. A reconnect gets the same snapshot a fresh connection does. So the client doesn't have to remember where it was or replay anything. It asks again and gets handed the current truth.&lt;/p&gt;

&lt;p&gt;Which means a recovered drop is invisible. The socket blinks out, reconnects a second later, the snapshot repaints the room, and the person who stepped away to read a ticket never knows a thing happened.&lt;/p&gt;

&lt;p&gt;Not every drop should retry, of course. If the room was deleted, the server closes with a specific code, and the client treats that as final. And after eight failed attempts in a row it gives up and asks the user to reload, because something like a revoked token will never succeed and looping forever helps nobody. It's tempting to reach for "retry everything" or "retry nothing," but both are wrong. Retry the transient stuff, quit on the terminal stuff.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part three: the error that only shows up in production
&lt;/h2&gt;

&lt;p&gt;Here's the one that cost me an afternoon.&lt;/p&gt;

&lt;p&gt;Once the heartbeat and reconnect were in, everything worked on my machine. Then the console started filling with this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Uncaught Error: Attempting to use a disconnected port object
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The port to the background had gone dead, and I was still trying to post to it. My first instinct was a guard: check that the port exists before using it. I already had that check. It didn't help.&lt;/p&gt;

&lt;p&gt;It didn't help because of &lt;em&gt;when&lt;/em&gt; Manifest V3 kills the worker. It can happen at any moment, including the microscopic gap between checking that your port is alive and actually calling &lt;code&gt;postMessage&lt;/code&gt; on it. You check, the check passes, Chrome kills the worker, you post, it throws. A time-of-check-to-time-of-use race, courtesy of a runtime that can pull the rug whenever it likes.&lt;/p&gt;

&lt;p&gt;You can't check your way out of a race like that. You have to catch it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;post&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;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&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="nx"&gt;port&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;port&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&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;span class="k"&gt;catch&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="nx"&gt;port&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// the worker died between the check and the call&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;Every send goes through that. The null check handles the common case, and the try/catch handles the case where the world changed underneath you mid-call. Once every &lt;code&gt;postMessage&lt;/code&gt; was wrapped, the errors stopped. I've come to treat it as a rule for MV3: any time you touch a port, assume it might already be gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The payoff
&lt;/h2&gt;

&lt;p&gt;Put the three together and the extension holds a live connection through everything a real workday throws at it. Idle stretches, sleeping laptops, flaky wifi, and Chrome's own eagerness to reclaim the service worker. The heartbeat keeps it awake, the reconnect makes drops invisible, and the guards keep a lifecycle race from spamming the console.&lt;/p&gt;

&lt;p&gt;None of this is exotic. It's the kind of plumbing that never shows up in a demo and only reveals itself after a socket has been open for a few real hours with real people on it. But that's exactly the gap that matters: the difference between something that works when you're testing it and something that works when your team is leaning on it mid-sprint.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build &lt;a href="https://repoker.app" rel="noopener noreferrer"&gt;Repoker&lt;/a&gt;, planning poker for GitHub Projects that writes the agreed estimate straight back to your board. The extension is on the &lt;a href="https://chromewebstore.google.com/detail/repoker-for-github/fgiigononmbpenkocdlghkdcjapbfkap" rel="noopener noreferrer"&gt;Chrome Web Store&lt;/a&gt; and &lt;a href="https://addons.mozilla.org/en-US/firefox/addon/repoker-for-github/" rel="noopener noreferrer"&gt;Firefox Add-ons&lt;/a&gt;. This post was &lt;a href="https://repoker.app/blog/websocket-manifest-v3" rel="noopener noreferrer"&gt;originally published on the Repoker blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>websocket</category>
    </item>
    <item>
      <title>We had a person whose job was to say "3, 2, 1"</title>
      <dc:creator>Matt Fauveau</dc:creator>
      <pubDate>Mon, 20 Jul 2026 19:05:47 +0000</pubDate>
      <link>https://dev.to/mfauveau/we-had-a-person-whose-job-was-to-say-3-2-1-5bck</link>
      <guid>https://dev.to/mfauveau/we-had-a-person-whose-job-was-to-say-3-2-1-5bck</guid>
      <description>&lt;p&gt;Here's how we pointed tickets.&lt;/p&gt;

&lt;p&gt;Everyone on Zoom. Ticket goes up. Each engineer types their number into the Slack thread but doesn't hit enter. Then you thumbs-up the message above to signal you're ready. Everyone waits. Once the reactions are in, the DoE counts down out loud: "three, two, one, point." And you hit enter.&lt;/p&gt;

&lt;p&gt;Read that again. We had built, by hand, out of a video call and an emoji, a synchronized reveal mechanism. That's it. That's what planning poker is. We just implemented it in people.&lt;/p&gt;

&lt;p&gt;And it half worked, which is the frustrating part. Someone's finger slips and they post early, so now everybody's seen a 5 before the count. Someone's Slack lags and their number lands two seconds late, which reads as hesitation whether or not it was. Someone joins the call late and asks what we're pointing, and we go around again. The countdown itself takes six seconds but the ritual around it takes ten minutes.&lt;/p&gt;

&lt;p&gt;The estimating was never the hard part. Five engineers who know the codebase can size a ticket in ninety seconds (that being said, some engineers will actually look at code, but that's a story for another time). What eats the hour is the choreography.&lt;/p&gt;

&lt;p&gt;And then the number goes nowhere. It sits in a Slack thread. The board lives in GitHub. Between them is a person remembering to type it in later, and often that person doesn't. I've seen sprint boards where a third of the tickets had no points, not because nobody estimated them but because the transcription step quietly failed. Two weeks later somebody asks why velocity looks off.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://repoker.app" rel="noopener noreferrer"&gt;Repoker&lt;/a&gt;, which is mostly just the countdown, automated. Pull in a ticket, everyone picks a card, everything flips at once because a machine is doing the flipping, and the agreed estimate writes itself back to GitHub Projects. Nobody says "three, two, one." Nobody thumbs-up anything. Nobody transcribes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv8yncvejemt4ok4faiug.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv8yncvejemt4ok4faiug.png" alt="Vote as a team, live. Everyone picks a card. Votes stay hidden until the reveal." width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It's entirely Cloudflare underneath: Workers for the app, Durable Objects with per-room SQLite so each session's state stays isolated, D1 for what outlives a session. Real-time voting is close to the canonical Durable Object use case. It's also themed around Casino Royale, just for fun.&lt;/p&gt;

&lt;p&gt;I got things wrong along the way. The reconnect logic was the fun one: a WebSocket loop that, under a specific tab-backgrounding condition, fired about eight thousand requests before I noticed.&lt;/p&gt;

&lt;p&gt;It's currently free, please &lt;a href="https://repoker.app" rel="noopener noreferrer"&gt;try it&lt;/a&gt; and give me feedback. If your team still has someone counting down out loud, I'd like to hear how that's going.&lt;/p&gt;

</description>
      <category>agile</category>
      <category>github</category>
      <category>showdev</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
