8 min read
What a Production WXT Extension Still Needs

WXT is the best thing to happen to browser-extension development in years. It gives you Vite-powered HMR, generates the manifest for both Chrome (MV3) and Firefox (MV2) from one config, wraps storage and messaging in typed APIs, and handles content-script lifecycle. The old version of this extension hand-rolled all of that with vite-plugin-web-extension, shell scripts, and {{browser}} manifest templating; WXT deleted the entire category.

But here’s the thing nobody tells you when you adopt a great framework: it scaffolds the building, not the engineering. The genuinely hard parts of a production extension live in the gaps WXT (correctly) doesn’t fill, because they’re application-specific. This article is a tour of those gaps, using the anonymous telemetry pipeline as the worked example.

Gap 1: the service worker is mortal, and there’s no cross-browser timer

In MV3, your background “page” is a service worker that the browser suspends after ~30 seconds of idle. This is the single fact that breaks the most naive designs. You cannot keep an in-memory queue. You cannot setInterval to flush it — the interval dies with the worker. You cannot hold a connection open.

The instinct is to reach for chrome.alarms, the MV3-blessed wake mechanism. And it works… on Chrome. But I’m targeting Firefox now and Safari/iOS eventually, and the cross-browser reality is grim: alarms is unreliable on iOS, setInterval dies with the worker, and Periodic Background Sync is Chrome-only. There is no background timer that works everywhere.

So the design goes event-driven instead of timer-driven. The queue flushes on concrete events that naturally wake the worker — a recipe got parsed, the browser started, a message arrived:

onMessage("recordParse", async ({ data }) => {
    const event = { eventId: crypto.randomUUID(), queuedAt: Date.now(), payload: data };
    await dispatch({ type: "enqueue", event });
    void flush();                       // flush on enqueue
});
onMessage("flushTelemetry", () => void flush()); // flush on request
browser.runtime.onStartup.addListener(() => void flush()); // flush on startup

And the one “timer” that is cross-browser? A setInterval in a content script — because that runs in the page’s context, not the worker’s, so it survives worker suspension and works on iOS while a tab is alive. So the content script sends a periodic heartbeat that nudges the background to drain:

const heartbeat = setInterval(() => {
    void sendMessage("flushTelemetry", undefined);
}, HEARTBEAT_INTERVAL_MS);
ctx.onInvalidated(() => clearInterval(heartbeat)); // WXT teardown hook

The background no-ops on an empty queue, so the heartbeat is nearly free. WXT gave me defineBackground and defineContentScript and the messaging to connect them — but the lifecycle strategy of “no timers, wake on events, heartbeat from the page” is mine to design.

Gap 2: concurrency — yes, an extension has races

It’s easy to assume single-threaded JavaScript means no concurrency bugs. Wrong. The background can receive two recordParse messages near-simultaneously, and each handler does a read-modify-write on the same stored queue: read state, reduce, write back. Interleave two of those and the second write clobbers the first’s enqueue. A classic lost-update race, in “single-threaded” JS, because the await points are yield points.

The fix is two patterns working together. First, single-writer: only the background ever mutates the shared queue. Content scripts and the popup send messages; they never write the queue directly. Second, serialize the writes through a promise chain so read-modify-write is atomic with respect to other dispatches:

let tail: Promise<unknown> = Promise.resolve();

export function dispatch(action: QueueAction): Promise<QueueState> {
    const run = tail.then(async () => {
        const current = await queueItem.getValue();
        const next = queueReducer(current, action);  // pure reducer
        if (next !== current) {
            await queueItem.setValue(next);
        }
        return next;
    });
    tail = run.catch(() => undefined); // keep the chain alive past a rejection
    return run;
}

Every mutation queues behind the previous one. The reducer being pure (the subject of its own discussion in the testing article) is what lets the I/O wrapper stay this thin — dispatch is the only place that touches storage, and it’s six lines.

Gap 3: storage is JSON, and it bites in two ways

WXT’s storage.defineItem is lovely, but the underlying extension storage is JSON-only. That sounds obvious until it bites you twice.

First, in the data model: no Date, no Map, no Set, no class instances. The queue stores timestamps as epoch-millisecond numbers, not Date objects, on purpose — so state round-trips through storage losslessly and the reducer stays trivially serializable.

Second — and this one cost me a real debugging session — in the writer. The popup let you toggle the telemetry opt-out, and writing settings threw DataCloneError: Proxy object could not be cloned. The cause: Vue wraps reactive state in Proxy objects, and extension storage uses the structured clone algorithm, which can’t clone a proxy. The settings object looked like a plain object and wasn’t. The fix is a one-liner that’s worth knowing exists:

function save(next: Settings) {
    // Strip Vue's reactive proxies; structured clone can't serialize them.
    return settingsItem.setValue(JSON.parse(JSON.stringify(next)));
}

WXT typed the storage API for me. It did not, and could not, warn me that a Vue proxy isn’t structured-cloneable. That’s the gap.

Gap 4: state vs. commands — pick the right channel

WXT hands you both storage (with a .watch()) and typed messaging, and the production decision is knowing which carries what. I split them on a clean line:

  • State that multiple contexts care about travels via storage + .watch(). When the user flips a setting, every content script and the background get the change broadcast to them at once — no tab registry, no fan-out messaging. “Telemetry turned off” reaches every tab for free:

    settingsItem.watch((next, prev) => {
        if (prev?.telemetryEnabled && !next.telemetryEnabled) {
            void dispatch({ type: "clear" }); // opt-out clears the pending queue
        }
    });
  • Commands and events — one context telling another to do something — travel via messaging: popup → content “toggle the reader in this tab,” content → background “record this parse.” These are directed and transient; they don’t belong in storage.

The old extension maintained a windowId:tabId registry and a CONNECT handshake to push state to tabs. With storage.watch as the broadcast bus, that entire machine is gone — onChanged is the broadcast. Picking the right primitive for each job deleted a whole subsystem.

Gap 5: network reliability across a worker that can die mid-flush

The flush has to assume it can be killed at any await — the worker might suspend mid-request, the network might drop, the device might sleep. The contract that survives all of it is at-least-once delivery: remove an event from the queue only after a confirmed 2xx, and tag each event with a UUID so the server can dedup inevitable resends.

if (!res.ok) throw new Error(`ingest responded ${res.status}`);
await dispatch({ type: "ack", eventIds });        // remove only now
await dispatch({ type: "flushSucceeded", at: now });

A flush interrupted halfway causes latency, never loss — the events are still in the queue, and the next wake trigger retries them. Failures increment a counter that drives exponential backoff (30s, capped at 1h), so a down endpoint doesn’t get hammered:

export function backoffMs(consecutiveFailures: number): number {
    const base = 30_000, max = 60 * 60_000;
    return Math.min(base * 2 ** Math.max(0, consecutiveFailures - 1), max);
}

And the queue is a bounded ring buffer (cap 500, drop-oldest, with a logged dropped counter so the cap is never silent) — because an unbounded queue behind a long-down endpoint is a memory leak with extra steps. None of this is WXT’s job. It’s distributed-systems hygiene that happens to run in a browser.

The takeaway

WXT is genuinely excellent, and I’d reach for it again without hesitation. But “the framework handles the extension stuff” is a half-truth that’ll burn you. The framework handles the plumbing of building — manifests, bundling, HMR, typed wrappers. It does not handle the engineering of running: the service-worker lifecycle, concurrency control, durable-state modeling, channel selection, and network reliability. Those are exactly the parts that separate a demo from something you’d let collect real data — which, conveniently, is where this series goes next: what that data is, and how it’s collected without collecting you.