<?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>Waking a dead service worker with Web Push, from a Cloudflare Worker</title>
      <dc:creator>Matt Fauveau</dc:creator>
      <pubDate>Wed, 29 Jul 2026 02:38:45 +0000</pubDate>
      <link>https://dev.to/mfauveau/waking-a-dead-service-worker-with-web-push-from-a-cloudflare-worker-1jnm</link>
      <guid>https://dev.to/mfauveau/waking-a-dead-service-worker-with-web-push-from-a-cloudflare-worker-1jnm</guid>
      <description>&lt;p&gt;Last time I wrote about &lt;a href="https://repoker.app/blog/websocket-manifest-v3" rel="noopener noreferrer"&gt;keeping a WebSocket alive inside a Manifest V3 extension&lt;/a&gt;, and the whole trick was staying awake: heartbeats, reconnects, guards. That solved the session that's already running. It did nothing for the case that actually matters most to a planning-poker tool, which is the teammate who isn't there yet.&lt;/p&gt;

&lt;p&gt;Here's the moment. The facilitator points a ticket in &lt;a href="https://repoker.app" rel="noopener noreferrer"&gt;Repoker&lt;/a&gt;. Three people are in the room. The fourth is deep in an editor somewhere, no GitHub tab open, no room open, nothing. The team either waits or pings them on Slack, which is exactly the busywork this tool exists to remove. I wanted their machine to just... tap them on the shoulder.&lt;/p&gt;

&lt;p&gt;You can't hold a connection open for that. Chrome kills an idle extension service worker in about thirty seconds, and no heartbeat can run when there's nothing alive to send it. The primitive for this is the opposite of staying awake: let the worker die, and have the browser's push service resurrect it for one job. That's Web Push, and getting it working from a Cloudflare Worker turned out to be the most interesting crypto detour I've taken in a while.&lt;/p&gt;

&lt;h2&gt;
  
  
  The extension half is almost suspiciously easy
&lt;/h2&gt;

&lt;p&gt;Extensions have been able to use the standard Push API &lt;a href="https://developer.chrome.com/docs/extensions/how-to/integrate/web-push" rel="noopener noreferrer"&gt;since Chrome 88&lt;/a&gt;. You add the &lt;code&gt;notifications&lt;/code&gt; permission, and the background service worker subscribes like any web page would:&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="nx"&gt;sub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;sw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;registration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pushManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;userVisibleOnly&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="na"&gt;applicationServerKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;urlB64ToBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;VAPID_PUBLIC_KEY&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 extension posts that subscription (an endpoint URL plus two keys) to Repoker's API, tied to the signed-in user. Receiving is a &lt;code&gt;push&lt;/code&gt; event listener that shows a notification and stores the click target:&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="nx"&gt;sw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;push&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;event&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;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;ext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&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="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="s2"&gt;basic&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;iconUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;icons/icon-128.png&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Vote now · &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;roomName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Estimating: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clicking it opens the GitHub issue, where the extension's on-page nudge takes over. The service worker was dead the entire time until the push arrived, woke it, and let it die again. No keepalive, no polling. It's the piece of MV3 that finally works &lt;em&gt;with&lt;/em&gt; the disposable-worker model instead of against it.&lt;/p&gt;

&lt;p&gt;So the client took an afternoon. The server is where the story is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nobody hands you Web Push on a Worker
&lt;/h2&gt;

&lt;p&gt;A push endpoint won't accept a plain JSON POST. Two RFCs stand between you and that notification: &lt;a href="https://www.rfc-editor.org/rfc/rfc8292" rel="noopener noreferrer"&gt;RFC 8292&lt;/a&gt; (VAPID, a signed JWT proving the push came from your server) and &lt;a href="https://www.rfc-editor.org/rfc/rfc8291" rel="noopener noreferrer"&gt;RFC 8291&lt;/a&gt; (the payload must be encrypted, per message, for that one subscription).&lt;/p&gt;

&lt;p&gt;The usual answer is the &lt;code&gt;web-push&lt;/code&gt; npm package, which leans on Node's crypto and https modules. On a Cloudflare Worker I'd rather own the hundred-odd lines of Web Crypto than fight compatibility shims, and honestly, I wanted to see the machinery. It fits in one file.&lt;/p&gt;

&lt;p&gt;VAPID is the friendlier half. Build a tiny JWT claiming the push service's origin as audience, sign it ES256 with a private key only the Worker holds:&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;aud&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;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;origin&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;exp&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;floor&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="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&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;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;b64urlEncode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;enc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&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="nx"&gt;aud&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subject&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;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ECDSA&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SHA-256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;enc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`vapid t=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;signingInput&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;b64urlEncode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;, k=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;publicKey&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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 matching public key ships inside the extension, and the browser binds every subscription to it. Steal a database of subscriptions and they're useless without the Worker's private key.&lt;/p&gt;

&lt;p&gt;The encryption half is where you slow down and read carefully. Each message derives fresh keys: an ephemeral ECDH agreement against the subscription's public key, mixed with the subscription's auth secret through two rounds of HKDF, producing a 16-byte content key and a 12-byte nonce:&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;keyInfo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;enc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;WebPush: info&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;uaPublic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;asPublic&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;ikm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;hkdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;authSecret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ecdh&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;keyInfo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;32&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;cek&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;hkdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;salt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ikm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;enc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Encoding: aes128gcm&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;16&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;nonce&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;hkdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;salt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ikm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;enc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Encoding: nonce&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then AES-128-GCM over the payload plus a one-byte delimiter, prefixed with a small header carrying the salt and the ephemeral public key. Every string in those &lt;code&gt;info&lt;/code&gt; parameters matters, including the null terminators. Get one byte wrong and nothing tells you which one; the push service just says no, or worse, says 201 and the browser silently drops a message it can't decrypt.&lt;/p&gt;

&lt;h2&gt;
  
  
  When I grow up, I want to be a watermelon
&lt;/h2&gt;

&lt;p&gt;That failure mode, wrong byte, no error, is why the best part of RFC 8291 is section 5. The spec authors ship a complete worked example: fixed keys, a fixed salt, and the plaintext "When I grow up, I want to be a watermelon", all the way down to the exact bytes of the final encrypted message.&lt;/p&gt;

&lt;p&gt;Which means the encryption core is testable in the strictest possible sense. I'd factored mine to accept an injected key and salt precisely so a test could pin it to that vector, and then, I'll admit, shipped without writing the test. The comment claiming testability sat there for a week until writing this post shamed me into it. It passes, byte for byte:&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;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;aes128gcmEncrypt&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;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;When I grow up, I want to be a watermelon&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nx"&gt;uaPublic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;authSecret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;asPrivate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;asPublic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;salt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;b64urlEncode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;VECTOR&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you implement this yourself, write that test first. It converts "why is the push service rejecting me" from an evening of guesswork into a failing assertion.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deciding who gets woken up
&lt;/h2&gt;

&lt;p&gt;The crypto is fussy but bounded. The design question with actual judgment in it is: when a round starts, whose machine buzzes?&lt;/p&gt;

&lt;p&gt;Repoker's answer lives in the room's Durable Object. When the facilitator points a ticket, it collects the room's members, subtracts the facilitator, subtracts everyone currently connected (they can already see the round), and sends an encrypted push to whoever remains. Dead subscriptions answer 404 or 410 and get pruned on the spot.&lt;/p&gt;

&lt;p&gt;Then there's the rule I'd argue matters more than all the cryptography: &lt;strong&gt;one push per room per fifteen minutes&lt;/strong&gt;. A facilitator flipping through ten tickets in a pointing session is one event, "we're estimating now", not ten notifications. The cooldown is even reserved &lt;em&gt;before&lt;/em&gt; sending, so a burst of activity can't race past it. Have you ever kept notifications on for an app that pinged you per item instead of per session? Neither has anyone else, for long. A notification channel is trust you spend; the fastest way to lose it is to be technically correct about every individual event.&lt;/p&gt;

&lt;p&gt;There's a toggle in the extension popup to turn the whole thing off, and signing out unsubscribes. That's table stakes, but it's the kind of table stakes that store reviewers now rightly ask you to document.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it stands
&lt;/h2&gt;

&lt;p&gt;One honest limitation: this is Chrome-first for now. Firefox runs MV3 backgrounds as event pages rather than service workers, so there's no &lt;code&gt;registration.pushManager&lt;/code&gt; there and the subscribe step quietly skips. The rest of the extension works fine in Firefox; the shoulder-tap is a follow-up.&lt;/p&gt;

&lt;p&gt;Put together with the last post, the nudge now has two layers. If you're looking at the ticket, the Point in Repoker button lights up and shows who's waiting. If you're looking at nothing at all, your OS taps you: &lt;em&gt;Vote now · Sprint 29&lt;/em&gt;. Click, and you're voting.&lt;/p&gt;

&lt;p&gt;Both are live in &lt;a href="https://repoker.app" rel="noopener noreferrer"&gt;Repoker&lt;/a&gt;, 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;. And if you're building anything on Workers that needs to reach a browser that isn't looking, the watermelon is waiting for you in section 5.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Why your GitHub App can't see your personal Projects</title>
      <dc:creator>Matt Fauveau</dc:creator>
      <pubDate>Thu, 23 Jul 2026 15:50:49 +0000</pubDate>
      <link>https://dev.to/mfauveau/why-your-github-app-cant-see-your-personal-projects-4d39</link>
      <guid>https://dev.to/mfauveau/why-your-github-app-cant-see-your-personal-projects-4d39</guid>
      <description>&lt;p&gt;I was recording a demo video. I had a GitHub project board with a tidy set of tickets on it, &lt;a href="https://repoker.app" rel="noopener noreferrer"&gt;Repoker&lt;/a&gt; installed and authorized, and a dropdown that was supposed to list my boards. The dropdown was empty.&lt;/p&gt;

&lt;p&gt;No error. No red text. Just an empty list where my project should have been, which is the most annoying kind of bug there is. If something had failed loudly I'd have known where to look. Instead I did what you'd do: assumed I'd fumbled a permission somewhere, and went back to the settings page to fix it.&lt;/p&gt;

&lt;p&gt;I did not fix it, because there was nothing to fix. What I eventually worked out is that &lt;strong&gt;a GitHub App can read organization-owned Projects and cannot read user-owned ones&lt;/strong&gt;, and no combination of checkboxes changes that. If you've been hunting for the permission that makes your personal Projects visible to your app, I can save you the couple of hours I spent: it isn't there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The permission that doesn't exist
&lt;/h2&gt;

&lt;p&gt;Here's the part that sends you in circles. Go to your GitHub App's settings and you'll find a Projects permission under &lt;strong&gt;Repository permissions&lt;/strong&gt;. Excellent, you think. That's the one. You set it to read and write, reinstall, and nothing changes.&lt;/p&gt;

&lt;p&gt;That checkbox governs the old repository-level project boards, not the ProjectsV2 boards that live at the account or organization level. Same word, different feature. So you keep looking, and you find another Projects permission under &lt;strong&gt;Organization permissions&lt;/strong&gt;, which does control ProjectsV2. Set that one and your org boards work.&lt;/p&gt;

&lt;p&gt;Then you go looking for the equivalent under account permissions, for the boards owned by &lt;em&gt;you&lt;/em&gt; rather than an org. And you scroll. And it isn't there. There is no account-level Projects permission to grant.&lt;/p&gt;

&lt;p&gt;That absence is the whole answer, but it reads like an oversight rather than a rule. I remember toggling things twice and reinstalling the app on my own account just to be sure I hadn't misread the page. Nothing. When a UI lacks the option you're looking for, your first instinct is that you're on the wrong screen, not that the capability doesn't exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the API actually says
&lt;/h2&gt;

&lt;p&gt;The GraphQL API is more direct about it, once you ask in a way that forces an answer.&lt;/p&gt;

&lt;p&gt;Listing ProjectsV2 for an account is the same query either way. The only thing that changes is the root field, &lt;code&gt;organization&lt;/code&gt; or &lt;code&gt;user&lt;/code&gt;, chosen from the account type GitHub reports for each installation:&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;owner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Organization&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;organization&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;user&lt;/span&gt;&lt;span class="dl"&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;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`
query($login: String!, $after: String) {
  &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;(login: $login) {
    projectsV2(first: 100, after: $after) {
      pageInfo { hasNextPage endCursor }
      nodes { id number title closed }
    }
  }
}`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Point that at an org and you get your boards. Point the identical query at a user login, authenticated as an App, and GitHub answers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Resource not accessible by integration
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That string is the one people paste into search bars at eleven at night, and it's frustratingly generic. It shows up for all sorts of permission problems, which is exactly why it sends you back to the settings page for another round of checkbox roulette. In this case it doesn't mean "you forgot a permission." It means "this door doesn't open for apps."&lt;/p&gt;

&lt;h2&gt;
  
  
  Why mine failed silently
&lt;/h2&gt;

&lt;p&gt;So the API does say something. Why was my dropdown just empty?&lt;/p&gt;

&lt;p&gt;Because of a line I'd written myself a week or two earlier, for perfectly reasonable-sounding reasons. Repoker lists your projects by fanning out across every account where the app is installed and merging the results:&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;lists&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;installations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nf"&gt;listProjectsForAccount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accountLogin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accountType&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="o"&gt;=&amp;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at that &lt;code&gt;.catch(() =&amp;gt; [])&lt;/code&gt;. The intent was defensive: if one installation out of five has a problem, don't blow up the entire list and show the user nothing. Degrade gracefully. Return what you can.&lt;/p&gt;

&lt;p&gt;Which is a good instinct, and it produced the worst possible outcome here. My personal account threw "Resource not accessible by integration," the catch swallowed it, and the fan-out cheerfully returned an empty array for that account. The merge succeeded. The endpoint returned 200. The UI rendered exactly what it was told: nothing.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Graceful degradation and silent failure are the same code path, distinguished only by whether you tell anyone.&lt;/em&gt; I'd written the first and shipped the second. The error was there the whole time, caught and dropped a few stack frames from a user who had no way to see it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing what I could actually fix
&lt;/h2&gt;

&lt;p&gt;I couldn't make GitHub expose personal Projects. That part is closed. What I could fix is the twenty minutes a user would otherwise spend re-reading a permissions page looking for a checkbox that was never printed.&lt;/p&gt;

&lt;p&gt;So the empty state stopped being empty. Anywhere Repoker can end up with no projects to show, it now says why, and says what to do instead:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;No projects found. Repoker works with &lt;strong&gt;organization&lt;/strong&gt; Projects. GitHub doesn't let apps access personal (user-owned) projects.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That text sits in the lobby when you create a room, in the project picker inside a room, and in the browser extension when it can't read the board attached to an issue. It's in the FAQ on the landing page too, because "can I use this with a personal project?" is a question people will ask before they ever install anything, and the honest answer up front is worth more than a support conversation later.&lt;/p&gt;

&lt;p&gt;The extension gets the sharpest version, because that's where you're most likely to hit it mid-flow with a ticket already open:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Repoker can't read this issue's project. GitHub only exposes organization projects to apps, not personal (user-owned) ones. Move the board to an organization and install Repoker there.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Notice what each of those does. It names the constraint, it makes clear the constraint is GitHub's and not something the user misconfigured, and it gives them the one move that actually works. No apology, no vague "something went wrong," no invitation to go check their permissions again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The workaround, plainly
&lt;/h2&gt;

&lt;p&gt;If you're hitting this yourself, the fix is to move the board under an organization and install the app there. Free organizations exist, and you can create one and transfer a project into it in a few minutes. It feels like overkill for a solo project, and I understand the reluctance, but it's the only path that works today.&lt;/p&gt;

&lt;p&gt;For teams it's usually moot, because the board is already org-owned. This bites hardest on exactly the people evaluating your tool for the first time, poking at it with a personal side project before they'd dream of installing it on the company org. Which is a rough audience to fail silently in front of.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took away
&lt;/h2&gt;

&lt;p&gt;The platform limitation cost me a couple of hours. The silent failure cost me far more, and it was entirely mine.&lt;/p&gt;

&lt;p&gt;I've started treating every &lt;code&gt;catch&lt;/code&gt; that returns an empty value as a small decision about who gets to know. Sometimes swallowing really is right, and one flaky installation shouldn't nuke a list. But "the list is empty" and "we couldn't read this account" are different facts, and if you collapse them into the same empty dropdown, you've decided your user doesn't need the difference. They do. They're the one who has to act on it.&lt;/p&gt;

&lt;p&gt;If you're building on the GitHub API and your Projects queries are coming back empty, check whether you're pointing at a user or an organization before you touch a single permission. And if you want to see the friendlier version of this failure in the wild, &lt;a href="https://repoker.app" rel="noopener noreferrer"&gt;Repoker&lt;/a&gt; will tell you exactly what's wrong the first time, which is the least I could do after it told me nothing at all.&lt;/p&gt;

</description>
      <category>github</category>
      <category>webdev</category>
      <category>api</category>
      <category>programming</category>
    </item>
    <item>
      <title>Porting a Chrome extension to Firefox without forking the code</title>
      <dc:creator>Matt Fauveau</dc:creator>
      <pubDate>Wed, 22 Jul 2026 16:44:02 +0000</pubDate>
      <link>https://dev.to/mfauveau/porting-a-chrome-extension-to-firefox-without-forking-the-code-4897</link>
      <guid>https://dev.to/mfauveau/porting-a-chrome-extension-to-firefox-without-forking-the-code-4897</guid>
      <description>&lt;p&gt;I shipped &lt;a href="https://repoker.app" rel="noopener noreferrer"&gt;Repoker's&lt;/a&gt; extension to the Chrome Web Store first, told myself Firefox could wait, and then a tester asked the obvious question: "Is this coming to Firefox?" So I sat down expecting an afternoon of small annoyances. Porting a Chrome extension to Firefox has a reputation for being fiddly, and the reputation is half-earned. Most of it really is the same code. The other half is a short list of differences that will each cost you an hour if you don't know they're coming.&lt;/p&gt;

&lt;p&gt;Both browsers now speak Manifest V3, which is the thing that makes this story short. Firefox landed MV3 support in version 121, so the days of maintaining a separate MV2 build for Firefox are behind us. One manifest version, one set of APIs, mostly one codebase. But "mostly" is doing some work in that sentence, and this post is about the gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  One codebase, two manifests
&lt;/h2&gt;

&lt;p&gt;The first decision was whether to keep two copies of the extension or build both from one source. Two copies is how you end up with a bug fixed in Chrome and still live in Firefox three weeks later. So the extension is a single codebase, and a tiny build step stamps out the browser-specific bits.&lt;/p&gt;

&lt;p&gt;The only real difference between the two manifests is how the background script is declared. Chrome MV3 wants a service worker. Firefox MV3 wants a background &lt;code&gt;scripts&lt;/code&gt; array. And Firefox needs one extra block that Chrome doesn't understand. The build script merges a shared &lt;code&gt;manifest.base.json&lt;/code&gt; with whichever target you're building:&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;firefox&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;manifest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;background&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;scripts&lt;/span&gt;&lt;span class="p"&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;background.js&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;manifest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;browser_specific_settings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;gecko&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;repoker@repoker.app&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;strict_min_version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;121.0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;data_collection_permissions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="p"&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;authenticationInfo&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="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;manifest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;background&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;service_worker&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;background.js&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 &lt;code&gt;gecko&lt;/code&gt; block is worth pausing on. The &lt;code&gt;id&lt;/code&gt; is a stable add-on identifier Firefox insists on for a signed extension, and &lt;code&gt;strict_min_version&lt;/code&gt; is where the "Firefox got MV3 in 121" fact turns into an actual line of config. The &lt;code&gt;data_collection_permissions&lt;/code&gt; field is newer: AMO, Mozilla's add-on store, now asks you to declare up front what data your extension collects. Repoker sends the user's GitHub auth token to its own server to authenticate the session, and it collects no analytics or telemetry, so the honest declaration is a single required permission, &lt;code&gt;authenticationInfo&lt;/code&gt;. Everything else in the manifest, the icons, the content scripts, the host permissions, is identical across both browsers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;chrome&lt;/code&gt; global that isn't there
&lt;/h2&gt;

&lt;p&gt;Here's the difference that touches the most code, and thankfully it's a one-liner to solve. Chrome and Edge expose the extension APIs on a global called &lt;code&gt;chrome&lt;/code&gt;. Firefox exposes the same surface as &lt;code&gt;browser&lt;/code&gt;. If your code is littered with &lt;code&gt;chrome.storage.local.get(...)&lt;/code&gt;, none of it resolves in Firefox.&lt;/p&gt;

&lt;p&gt;You could sprinkle &lt;code&gt;if (typeof browser !== "undefined")&lt;/code&gt; everywhere. Don't. Alias it once and use the alias everywhere:&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;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ext&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;chrome&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;globalThis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's a subtlety that makes this cleaner than it used to be. The old &lt;code&gt;chrome.*&lt;/code&gt; APIs were callback-based, and &lt;code&gt;browser.*&lt;/code&gt; promisified. That mismatch used to force a polyfill. But every API Repoker touches, storage, runtime, identity, returns a promise on both browsers under MV3, so the alias is genuinely all you need. Import &lt;code&gt;ext&lt;/code&gt;, call &lt;code&gt;ext.storage&lt;/code&gt;, &lt;code&gt;ext.identity&lt;/code&gt;, &lt;code&gt;ext.runtime&lt;/code&gt;, and forget which browser you're in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The redirect that worked everywhere except Firefox
&lt;/h2&gt;

&lt;p&gt;Now the first bug that only Firefox could produce. Sign-in worked perfectly in Chrome, and in Firefox it died with a blunt &lt;code&gt;invalid redirect_uri&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Repoker authenticates the extension with &lt;code&gt;identity.launchWebAuthFlow&lt;/code&gt;. The extension asks the browser for its callback URL, sends the user to Repoker to sign in, and the server redirects a freshly minted token back to that callback:&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;redirectUri&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRedirectURL&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;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;REPOKER_ORIGIN&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/api/extension/connect?redirect_uri=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;redirectUri&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&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;finalUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launchWebAuthFlow&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="na"&gt;interactive&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The catch is what &lt;code&gt;getRedirectURL()&lt;/code&gt; actually returns, because it differs by browser. On Chromium it hands back &lt;code&gt;https://&amp;lt;id&amp;gt;.chromiumapp.org/&lt;/code&gt;. On Firefox it's &lt;code&gt;https://&amp;lt;id&amp;gt;.extensions.allizom.org/&lt;/code&gt;. Two different vendor-controlled hosts for the exact same job.&lt;/p&gt;

&lt;p&gt;My server, quite reasonably, refused to redirect a token to any host it didn't recognize. You never want to bounce a freshly minted credential to an arbitrary URL, so the endpoint validates the redirect target against an allowlist. The bug was that the allowlist only knew about Chrome:&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;function&lt;/span&gt; &lt;span class="nf"&gt;isExtensionRedirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uri&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;u&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;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;protocol&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.chromiumapp.org&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
     &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.extensions.allizom.org&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;Adding the Firefox host to the check fixed it. The lesson I took away: any time a browser hands you a magic URL, assume the other browser's magic URL looks different. The security posture stays the same, you're still only ever redirecting to a vendor host you trust, but "the vendor host" is now two hosts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that wasn't about Firefox at all
&lt;/h2&gt;

&lt;p&gt;The second Firefox-only failure was sneakier, because the fix belonged somewhere else entirely.&lt;/p&gt;

&lt;p&gt;With auth working, I opened a room in Firefox and it hung on "Linking this issue to the room." The console showed a Content Security Policy violation: the page had blocked a connection to &lt;code&gt;wss://repoker.app&lt;/code&gt;. The socket that carries every vote was refusing to open.&lt;/p&gt;

&lt;p&gt;The reason is that a content script runs in the page's world, so it inherits the page's CSP. GitHub ships a strict &lt;code&gt;connect-src&lt;/code&gt;, and Firefox enforces it against content scripts where Chrome had been letting it slide. So the very code that worked in Chrome was never actually allowed to work, and only Firefox was strict enough to say so.&lt;/p&gt;

&lt;p&gt;The fix was to move the WebSocket out of the content script and into the background, which isn't subject to any page's CSP. That turned out to be the right move for a completely separate reason involving Manifest V3's service worker lifecycle, which I wrote about in &lt;a href="https://repoker.app/blog/websocket-manifest-v3" rel="noopener noreferrer"&gt;making a WebSocket survive Manifest V3&lt;/a&gt;. Two different problems, one architecture change that solved both. Have you noticed how often that happens? The constraint you resent ends up pushing you toward the design you should have picked anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting through AMO review
&lt;/h2&gt;

&lt;p&gt;One last difference that has nothing to do with code and everything to do with shipping. Mozilla reviews add-on source. Because the extension is bundled with esbuild, the code AMO sees in the package isn't the code I wrote, so the submission has to include the source and instructions to reproduce the build.&lt;/p&gt;

&lt;p&gt;That pushed me to make the extension genuinely self-buildable, which is a good discipline regardless. From the source archive, a reviewer runs &lt;code&gt;npm install&lt;/code&gt; and one build command and gets back the exact &lt;code&gt;dist/&lt;/code&gt; that was uploaded. The one wrinkle worth flagging: the extension imports a shared types file from outside its own folder, so the source archive has to preserve that relative path or the build won't resolve it. I found that out by extracting the archive into a clean directory and building it myself, which is exactly what a reviewer does, and exactly the check you want to run before you submit rather than after they bounce it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the port actually cost
&lt;/h2&gt;

&lt;p&gt;Add it all up and porting a Chrome extension to Firefox came to this: one build flag, a manifest block, a one-line API alias, one allowlist entry, and a socket that moved to where it should have lived from the start. The genuinely Firefox-specific work was an afternoon, most of it the two bugs above. The self-buildable packaging for AMO took longer than the code, and it was worth it.&lt;/p&gt;

&lt;p&gt;If you're weighing whether to bother, I'd say do it. A second store listing is real distribution, the shared-codebase tax is close to zero once these differences are handled, and every Firefox user who installs it is someone Chrome was never going to reach. Repoker's extension is live on both 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; now. Same code, two homes.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>firefox</category>
      <category>javascript</category>
    </item>
    <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>
