Chrome Extension Websocket Service Workers — Best Practices

19 min read

WebSocket Connections from Service Workers

Overview

WebSockets provide real-time, bidirectional communication — but MV3 service workers are a hostile environment for persistent connections. The service worker can terminate after 30 seconds of inactivity, destroying any open WebSocket. This guide covers eight patterns for maintaining reliable WebSocket connections in Chrome extensions, from offscreen document hosting to graceful fallback strategies.


Pattern 1: WebSocket Basics in MV3 Service Workers

You can open a WebSocket directly in a service worker, but the connection dies when Chrome terminates it:

// background.ts — Direct WebSocket (fragile)
let socket: WebSocket | null = null;

function connect(url: string): void {
  socket = new WebSocket(url);

  socket.onopen = () => console.log("WebSocket connected");

  socket.onmessage = (event) => {
    chrome.runtime.sendMessage({
      type: "ws-event",
      data: JSON.parse(event.data),
    }).catch(() => {}); // No listener if popup is closed
  };

  socket.onclose = (event) => {
    console.warn(`WebSocket closed: ${event.code}`);
    socket = null;
    // Service worker will terminate soon — no reliable reconnection
  };
}

The core problem: Chrome terminates idle service workers after ~30 seconds. Direct connections are only suitable for short-lived, request-response exchanges — not persistent subscriptions.


Pattern 2: Offscreen Document as a Persistent WebSocket Host

Move the WebSocket into an offscreen document, which runs in a normal page context and is not subject to service worker lifecycle limits:

// offscreen/manager.ts
class OffscreenManager {
  private creating: Promise<void> | null = null;

  async ensure(): Promise<void> {
    if (await this.exists()) return;
    if (this.creating) { await this.creating; return; }

    this.creating = chrome.offscreen.createDocument({
      url: "offscreen.html",
      reasons: [chrome.offscreen.Reason.WEB_RTC],
      justification: "Maintain persistent WebSocket connection",
    });
    try { await this.creating; } finally { this.creating = null; }
  }

  async exists(): Promise<boolean> {
    const contexts = await chrome.runtime.getContexts({
      contextTypes: [chrome.runtime.ContextType.OFFSCREEN_DOCUMENT],
    });
    return contexts.length > 0;
  }

  async close(): Promise<void> {
    if (await this.exists()) await chrome.offscreen.closeDocument();
  }
}

export const offscreen = new OffscreenManager();
// offscreen-ws.ts — Runs inside the offscreen document
let socket: WebSocket | null = null;

chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
  if (msg.type === "ws-connect") {
    connectSocket(msg.url);
    sendResponse({ ok: true });
  } else if (msg.type === "ws-send") {
    const ok = socket?.readyState === WebSocket.OPEN;
    if (ok) socket!.send(JSON.stringify(msg.data));
    sendResponse({ ok });
  } else if (msg.type === "ws-status") {
    sendResponse({ connected: socket?.readyState === WebSocket.OPEN });
  }
  return true;
});

function connectSocket(url: string): void {
  socket?.close();
  socket = new WebSocket(url);
  socket.onopen = () =>
    chrome.runtime.sendMessage({ type: "ws-state", state: "open" });
  socket.onmessage = (event) =>
    chrome.runtime.sendMessage({ type: "ws-message", data: JSON.parse(event.data) });
  socket.onclose = (event) =>
    chrome.runtime.sendMessage({ type: "ws-state", state: "closed", code: event.code });
}

Pattern 3: Reconnection with Exponential Backoff

Network failures and server restarts are inevitable. Use exponential backoff with jitter:

// reconnect.ts
class ReconnectingWebSocket {
  private socket: WebSocket | null = null;
  private attempt = 0;
  private timer: ReturnType<typeof setTimeout> | null = null;
  private closed = false;

  constructor(
    private url: string,
    private onMessage: (data: unknown) => void,
    private onStateChange: (state: string) => void,
    private baseDelay = 1000,
    private maxDelay = 30_000,
    private maxAttempts = 20
  ) {}

  connect(): void {
    this.closed = false;
    this.attempt = 0;
    this.createSocket();
  }

  disconnect(): void {
    this.closed = true;
    if (this.timer) clearTimeout(this.timer);
    this.socket?.close(1000, "Client disconnect");
    this.socket = null;
  }

  send(data: unknown): boolean {
    if (this.socket?.readyState !== WebSocket.OPEN) return false;
    this.socket.send(JSON.stringify(data));
    return true;
  }

  private createSocket(): void {
    this.socket = new WebSocket(this.url);
    this.socket.onopen = () => { this.attempt = 0; this.onStateChange("open"); };
    this.socket.onmessage = (e) => this.onMessage(JSON.parse(e.data));
    this.socket.onclose = () => {
      this.onStateChange("closed");
      if (!this.closed) this.scheduleReconnect();
    };
  }

  private scheduleReconnect(): void {
    if (this.attempt >= this.maxAttempts) {
      this.onStateChange("failed");
      return;
    }
    const delay = Math.min(this.baseDelay * 2 ** this.attempt, this.maxDelay);
    const jitter = delay * 0.3 * Math.random();
    this.attempt++;
    this.timer = setTimeout(() => this.createSocket(), delay + jitter);
  }
}

Pattern 4: Message Queuing During Disconnections

Buffer outbound messages while the socket is down and flush them on reconnect:

// queue.ts
interface QueuedMessage {
  data: unknown;
  timestamp: number;
  ttl: number;
}

class MessageQueue {
  private queue: QueuedMessage[] = [];

  constructor(private maxSize = 200, private defaultTTL = 60_000) {}

  enqueue(data: unknown, ttl?: number): void {
    if (this.queue.length >= this.maxSize) this.queue.shift();
    this.queue.push({ data, timestamp: Date.now(), ttl: ttl ?? this.defaultTTL });
  }

  flush(send: (data: unknown) => boolean): { sent: number; dropped: number } {
    const now = Date.now();
    let sent = 0, dropped = 0;

    while (this.queue.length > 0) {
      const msg = this.queue[0];
      if (now - msg.timestamp > msg.ttl) { this.queue.shift(); dropped++; continue; }
      if (!send(msg.data)) break;
      this.queue.shift();
      sent++;
    }
    return { sent, dropped };
  }

  get size(): number { return this.queue.length; }
}

Integrate with the reconnecting socket:

// offscreen-ws.ts
const queue = new MessageQueue(500, 120_000);
const ws = new ReconnectingWebSocket(
  "wss://api.example.com/ws",
  (data) => chrome.runtime.sendMessage({ type: "ws-message", data }),
  (state) => {
    chrome.runtime.sendMessage({ type: "ws-state", state });
    if (state === "open") queue.flush((d) => ws.send(d));
  }
);

Pattern 5: Heartbeat / Ping-Pong Keep-Alive

Detect dead connections before the TCP timeout by exchanging periodic heartbeats:

// heartbeat.ts
class HeartbeatManager {
  private pingTimer: ReturnType<typeof setInterval> | null = null;
  private pongTimer: ReturnType<typeof setTimeout> | null = null;

  constructor(
    private intervalMs: number,
    private timeoutMs: number,
    private sendFn: (data: unknown) => boolean,
    private onTimeout: () => void
  ) {}

  start(): void {
    this.stop();
    this.pingTimer = setInterval(() => this.ping(), this.intervalMs);
  }

  stop(): void {
    if (this.pingTimer) clearInterval(this.pingTimer);
    if (this.pongTimer) clearTimeout(this.pongTimer);
    this.pingTimer = this.pongTimer = null;
  }

  handlePong(): void {
    if (this.pongTimer) { clearTimeout(this.pongTimer); this.pongTimer = null; }
  }

  private ping(): void {
    if (!this.sendFn({ type: "ping", ts: Date.now() })) return;
    this.pongTimer = setTimeout(() => this.onTimeout(), this.timeoutMs);
  }
}

// Usage
const heartbeat = new HeartbeatManager(25_000, 10_000,
  (data) => ws.send(data),
  () => { ws.disconnect(); ws.connect(); }
);
// Start on "open", stop on "closed"
// Call heartbeat.handlePong() when a pong message arrives

Pattern 6: Typed WebSocket Message Protocol

Define a compile-time-safe protocol for all WebSocket messages:

// protocol.ts
import { createMessenger } from "@theluckystrike/webext-messaging";

type ServerMessages = {
  "chat:message": { id: string; user: string; text: string; timestamp: number };
  "presence:update": { userId: string; status: "online" | "away" | "offline" };
};

type ClientMessages = {
  "chat:send": { text: string; replyTo?: string };
  "room:join": { roomId: string };
};

interface WireMessage<T = unknown> {
  event: string;
  payload: T;
  id: string;
}

function createTypedSender(rawSend: (data: unknown) => boolean) {
  return <K extends keyof ClientMessages>(event: K, payload: ClientMessages[K]) => {
    const wire: WireMessage = { event, payload, id: crypto.randomUUID() };
    return rawSend(wire);
  };
}

class MessageRouter {
  private handlers = new Map<string, (payload: unknown) => void>();

  on<K extends keyof ServerMessages>(
    event: K, handler: (payload: ServerMessages[K]) => void
  ): void {
    this.handlers.set(event, handler as (payload: unknown) => void);
  }

  route(wire: WireMessage): boolean {
    const handler = this.handlers.get(wire.event);
    if (!handler) return false;
    handler(wire.payload);
    return true;
  }
}

// Usage
const router = new MessageRouter();
const send = createTypedSender((data) => ws.send(data));

router.on("chat:message", (msg) => {
  // msg is typed as { id, user, text, timestamp }
  chrome.runtime.sendMessage({ type: "chat-message", data: msg });
});

send("chat:send", { text: "Hello!", replyTo: "msg-123" });

Pattern 7: Broadcasting WebSocket Events to Popup and Content Scripts

WebSocket data arrives in the offscreen document but must reach the popup, side panel, and content scripts:

// background.ts — Central event broadcaster
chrome.runtime.onMessage.addListener((msg) => {
  if (msg.type === "ws-message") broadcastToAll(msg.data);
  if (msg.type === "ws-state") broadcastStatus(msg.state === "open");
});

async function broadcastToAll(data: unknown): Promise<void> {
  // Extension pages (popup, side panel, options)
  chrome.runtime.sendMessage({ type: "ws:data", data }).catch(() => {});

  // All content scripts
  const tabs = await chrome.tabs.query({});
  for (const tab of tabs) {
    if (tab.id) {
      chrome.tabs.sendMessage(tab.id, { type: "ws:data", data }).catch(() => {});
    }
  }
}

async function broadcastStatus(connected: boolean): Promise<void> {
  chrome.runtime.sendMessage({ type: "ws:status", connected }).catch(() => {});
  const tabs = await chrome.tabs.query({});
  for (const tab of tabs) {
    if (tab.id) {
      chrome.tabs.sendMessage(tab.id, { type: "ws:status", connected }).catch(() => {});
    }
  }
}
// content-script.ts
chrome.runtime.onMessage.addListener((msg) => {
  if (msg.type === "ws:data") renderUpdate(msg.data);
  if (msg.type === "ws:status") updateConnectionIndicator(msg.connected);
});

Pattern 8: Fallback from WebSocket to Polling

When the offscreen document is unavailable (already in use, or older Chrome), fall back to HTTP polling from the service worker:

// background.ts
class PollingTransport {
  private timer: ReturnType<typeof setInterval> | null = null;
  private baseUrl = "";
  private lastEventId = "0";

  constructor(
    private intervalMs: number,
    private onMessage: (data: unknown) => void
  ) {}

  start(wsUrl: string): void {
    this.baseUrl = wsUrl.replace(/^wss?:/, "https:").replace(/\/ws$/, "/poll");
    this.timer = setInterval(() => this.poll(), this.intervalMs);
    this.poll();
  }

  stop(): void {
    if (this.timer) { clearInterval(this.timer); this.timer = null; }
  }

  private async poll(): Promise<void> {
    try {
      const res = await fetch(`${this.baseUrl}/events?since=${this.lastEventId}`);
      if (!res.ok) return;
      const events: Array<{ id: string; data: unknown }> = await res.json();
      for (const e of events) { this.lastEventId = e.id; this.onMessage(e.data); }
    } catch { /* retry on next interval */ }
  }
}

async function createTransport(onMessage: (data: unknown) => void) {
  if (chrome.offscreen) {
    const contexts = await chrome.runtime.getContexts({
      contextTypes: [chrome.runtime.ContextType.OFFSCREEN_DOCUMENT],
    });
    if (contexts.length === 0) {
      // Use offscreen WebSocket (Patterns 2-5)
      return createOffscreenTransport(onMessage);
    }
  }
  console.warn("Offscreen unavailable — falling back to HTTP polling");
  return new PollingTransport(5000, onMessage);
}

Summary

Pattern Use Case
Direct WebSocket in SW Short-lived request-response only; drops on termination
Offscreen document host Persistent connection that survives SW lifecycle
Exponential backoff Reliable reconnection without thundering-herd
Message queuing Buffer outbound data during disconnections
Heartbeat / ping-pong Detect dead connections before TCP timeout
Typed protocol Compile-time safety for all WS message types
Event broadcasting Distribute real-time data to popup, side panel, content scripts
Polling fallback HTTP long-poll when offscreen document is unavailable

WebSocket reliability in MV3 comes down to one rule: never trust the service worker to stay alive. Host the connection in an offscreen document, add reconnection and queuing, and always have a fallback plan. -e —

Part of the Chrome Extension Guide by theluckystrike. Built at zovo.one.