Making a WebSocket survive Chrome's Manifest V3

작성자

카테고리:

← 피드로
DEV Community · Matt Fauveau · 2026-07-22 개발(SW)

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.

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.

This is the story of that bug, and what it actually takes to keep a WebSocket alive inside a Manifest V3 extension.

TL;DR: 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.

Why the socket doesn’t live where you’d expect

First, some context on how the extension is wired, because it explains why this bug was even possible.

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 Durable Object that owns the room’s state. Standard real-time stuff.

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?

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 connect-src, and Firefox enforces it against content scripts. Try to open wss://… from there and the browser refuses. (Chrome is more lenient here, but I wanted one codebase, not two.)

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 chrome.runtime port, and the background relays frames both ways. Problem solved.

Except moving the socket into the service worker is exactly what set up the disconnect bug. Which brings us to Manifest V3.

What Manifest V3 took away

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.

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, after about 30 seconds of inactivity. The idea is efficiency: don’t keep code resident that isn’t doing anything.

But a WebSocket is 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.

That’s the disconnect. Not a network problem. A lifecycle problem. The fix has three parts.

Part one: a heartbeat to stay awake

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

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 ping frame on the socket, and that traffic keeps the worker’s idle timer from ever reaching zero.

// Keep the port busy roughly twice per MV3 idle window (~30s).
const HEARTBEAT_MS = 20_000;
this.heartbeat = setInterval(() => this.post({ __ping: true }), HEARTBEAT_MS);

Enter fullscreen mode Exit fullscreen mode

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.

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:

this.ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair("ping", "pong"));

Enter fullscreen mode Exit fullscreen mode

So the heartbeat keeps the extension’s service worker awake, but the server answers each ping with a pong 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.

Part two: reconnect like nothing happened

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.

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.

const delay = this.reconnectDelay;
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 15_000);
this.reconnectTimer = setTimeout(() => this.open(), delay);

Enter fullscreen mode Exit fullscreen mode

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.

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.

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.

Part three: the error that only shows up in production

Here’s the one that cost me an afternoon.

Once the heartbeat and reconnect were in, everything worked on my machine. Then the console started filling with this:

Uncaught Error: Attempting to use a disconnected port object

Enter fullscreen mode Exit fullscreen mode

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.

It didn’t help because of when 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 postMessage 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.

You can’t check your way out of a race like that. You have to catch it.

post(msg) {
  if (!this.port) return;
  try {
    this.port.postMessage(msg);
  } catch {
    this.port = null; // the worker died between the check and the call
  }
}

Enter fullscreen mode Exit fullscreen mode

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 postMessage 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.

The payoff

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.

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.

I build Repoker, planning poker for GitHub Projects that writes the agreed estimate straight back to your board. The extension is on the Chrome Web Store and Firefox Add-ons. This post was originally published on the Repoker blog.

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다