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.