Reducing Service Worker Cold-Start Latency in MV3

Cut MV3 service worker cold-start latency: measure idle eviction cost, shrink bundles, defer heavy init, code-split dynamic imports, cache parsed config, and minimize synchronous listener work.

Published June 19, 2026 Updated June 19, 2026 9 min read
Table of Contents

An MV3 extension’s service worker does not stay running between events. After roughly 30 seconds of inactivity the browser evicts it, and the next incoming event — a toolbar click, an incoming message, a chrome.alarms callback — pays the full cold-start cost before it can respond. For large or poorly structured extensions that cost reaches 400–800 ms, turning a toolbar click into a noticeable stall. This guide covers every lever available to cut that latency, from bundle size to storage pre-warming. For the broader profiling workflow and Performance panel setup, see the performance profiling & optimization guide.

Why every event pays cold-start cost after idle eviction

When the browser evicts a service worker it discards the parsed script, the module graph, and all module-scope variables. The V8 bytecode cache may survive on disk and eliminate re-parse cost on subsequent starts, but the top-level execution phase always runs from scratch.

Event arrives (e.g. chrome.runtime.onMessage)
        │
        ▼
  Worker process starts
        │
        ├─► Fetch + parse background.js  ← bundle size pays here
        ├─► Execute top-level module code ← eager imports pay here
        ├─► Re-register all event listeners ← must complete synchronously
        └─► Fire the queued event callback  ← user perceives latency from here

The browser queues the incoming event while the worker starts, so no events are dropped. User-perceived latency equals the sum of all phases above. Cold starts on fresh installs and after browser updates always pay full parse cost regardless of any cache.

Step 1 — Measure the actual cold-start baseline

Establish a numeric baseline before changing anything. Assign performance.now() at the very first line of the service worker — before any imports — and log elapsed time when the first event fires.

 1// background.ts — absolute first line, before all imports
 2const COLD_START_TS = performance.now();
 3
 4import { CONFIG_KEYS } from './constants.js';
 5
 6// Register listeners synchronously — Chrome requirement
 7chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
 8  console.log(`[cold-start] first message in ${(performance.now() - COLD_START_TS).toFixed(1)} ms`);
 9  void handleMessage(msg, sendResponse);
10  return true;
11});
12
13async function handleMessage(
14  msg: { type: string; payload?: unknown },
15  respond: (r: unknown) => void
16): Promise<void> {
17  const { processMessage } = await import('./message-processor.js'); // lazy
18  respond(await processMessage(msg));
19}

Execution context: Service worker global scope. performance.now() is available in MV3 service workers on Chrome 88+, Firefox 109+, and Safari 15.4+. Firefox clamps the value to 1 ms resolution — sufficient for cold-start measurement but not sub-millisecond micro-benchmarks. Force eviction by opening chrome://extensions and clicking Terminate, then trigger the event. Average ten runs. Readings 2–10 will be lower than reading 1 because the V8 bytecode cache warms after the first parse.*

A typical unoptimized extension ships with 300–600 ms of cold-start cost. Target under 80 ms for the synchronous phases (parse + execute + listener registration).

Step 2 — Shrink the bundle sent to the parser

Parse time scales linearly with bundle size. A 600 KB unminified background.js takes 400–600 ms to parse on a mid-range device before a single line of application code executes.

 1// vite.config.ts production build — code-split heavy optional deps
 2{
 3  "build": {
 4    "rollupOptions": {
 5      "output": {
 6        "manualChunks": {
 7          "pdf":  ["pdfjs-dist"],
 8          "i18n": ["./src/i18n-data.ts"]
 9        }
10      }
11    },
12    "minify": "esbuild"
13  }
14}

Execution context: Build configuration. After building, run du -sh dist/background.js and compare to the pre-optimization baseline. Use rollup-plugin-visualizer (Vite) or webpack-bundle-analyzer after every significant dependency addition. Target under 80 KB minified for the synchronously loaded background chunk.*

Step 3 — Defer heavy initialization with lazy getters

The most common source of top-level overhead is calling initialization functions at module scope — compiling regex patterns, building lookup tables, constructing rule engines. These run on every cold start regardless of which event woke the worker.

 1// background.ts — lazy getter defers init until first use
 2let _engine: Promise<RuleEngine> | null = null;
 3
 4function getRuleEngine(): Promise<RuleEngine> {
 5  return (_engine ??= (async () => {
 6    const { RuleEngine } = await import('./rule-engine.js');
 7    const config = await getCachedConfig();
 8    return new RuleEngine(config.rules);
 9  })());
10}
11
12// Only initializes on the first matching event, not on every cold start
13chrome.declarativeNetRequest.onRuleMatchedDebug?.addListener(async (info) => {
14  const engine = await getRuleEngine(); // ~80 ms first call, ~0 ms thereafter
15  await engine.recordMatch(info);
16});

Execution context: Service worker. The _engine promise is cached for the lifetime of the worker. On eviction it resets to null, so the factory runs once per lifetime — the same as if it had run eagerly. Firefox and Safari handle this pattern identically. The key constraint: listener registration must still happen synchronously at the top level. Defer what the listeners do, not the registration itself.*

Step 4 — Code-split with dynamic imports

When one event type triggers most cold starts but only uses a fraction of the codebase, the unused code is pure cold-start tax. Dynamic import() moves heavy modules out of the synchronously parsed chunk and defers their fetch to first use.

 1// manifest.json: "background": { "service_worker": "background.js", "type": "module" }
 2
 3chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
 4  if (msg.type === 'GENERATE_REPORT') {
 5    import('./report-generator.js')           // ~200 KB chunk, only fetched here
 6      .then(({ generateReport }) => generateReport(msg.payload))
 7      .then(result => sendResponse({ result }))
 8      .catch(err  => sendResponse({ error: String(err) }));
 9    return true; // keep port open
10  }
11  sendResponse({ ignored: true });
12  return false;
13});

Execution context: ESM module service worker — requires "type": "module" in the "background" declaration. Chrome 91+, Firefox 89+, Safari 16+ support dynamic import() in service workers. On earlier Safari versions the import silently fails; wrap in a try/catch and send an error response rather than leaving the message port hanging. Without "type": "module", dynamic import() throws TypeError at runtime on all browsers.*

Step 5 — Cache parsed config in storage

Configuration that requires non-trivial processing — compiling filter lists, validating JSON schemas — should be processed once and stored in chrome.storage.local. On subsequent cold starts, read the pre-processed result directly.

 1// config-cache.ts
 2export async function getProcessedConfig(): Promise<ProcessedConfig> {
 3  const { configCache } = await chrome.storage.local.get('configCache');
 4
 5  if (configCache?.version === CURRENT_SCHEMA_VERSION) {
 6    return configCache; // fast path: ~2 ms
 7  }
 8
 9  // Slow path: only on first install, update, or schema version bump
10  const { rawConfig } = await chrome.storage.sync.get('rawConfig');
11  const { compileRules } = await import('./rule-compiler.js');
12  const processed: ProcessedConfig = {
13    version: CURRENT_SCHEMA_VERSION,
14    rules: await compileRules(rawConfig ?? {}),
15    compiledAt: Date.now(),
16  };
17  await chrome.storage.local.set({ configCache: processed });
18  return processed;
19}

Execution context: Service worker. chrome.storage.local.get costs 1–3 ms; compileRules() may cost 50–200 ms depending on rule set size. Increment CURRENT_SCHEMA_VERSION on any extension update that changes the rule format to force cache invalidation. Batch the config read with other startup reads: chrome.storage.local.get(['configCache', 'userState']) collapses two round-trips into one. Firefox and Safari support the same storage.local surface.*

Step 6 — Minimize synchronous work in listener callbacks

Listeners run synchronously before the event loop can yield. Storage reads and heavy computation inside the synchronous listener body block the thread. Move all async work into an IIFE after the synchronous dispatch check.

 1chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
 2  if (msg.type !== 'PROCESS_SELECTION') { return false; }
 3
 4  (async () => {
 5    const config = await getProcessedConfig();
 6    const { processSelection } = await import('./selection.js');
 7    sendResponse({ result: await processSelection(msg.payload, config) });
 8  })().catch(err => sendResponse({ error: String(err) }));
 9
10  return true; // mandatory: keeps the message port open for the async response
11});

Execution context: Service worker. Do not mark the listener itself async — an async listener returns a Promise, which the extensions runtime does not treat as a port-keep-open signal on Chrome. The return true inside the synchronous body is what keeps the port open. Missing it produces The message port closed before a response was received in the sender.*

Cross-browser variation

  • Chrome — Baseline. V8 bytecode cache cuts re-parse cost after the first cold start; invalidated on extension or browser updates.
  • Firefox — SpiderMonkey parses equivalent bundles 20–40% faster than V8. Firefox development numbers underestimate Chrome cold-start cost — always measure on Chrome before shipping.
  • Safari — Idle eviction window is shorter than 30 seconds and shrinks further in low-power mode on iOS (as short as 5 seconds). Dynamic import() requires Safari 16+; transpile to a non-ESM module worker for broader compatibility.
  • Edge — Identical to Chrome. Tighter background memory caps can trigger eviction under memory pressure before the idle timeout.

Verification

  1. Build a production bundle and check size: du -sh dist/background.js.
  2. In chrome://extensions, click Terminate to force eviction, then trigger the primary event. Read the [cold-start] console line. Record ten runs and average.
  3. In the Performance panel flame chart, confirm all addListener calls appear in the Evaluate Script block — not inside an async task.
  4. In Application → Extension Storage → Local, verify the configCache key is present and compiledAt reflects install/update time, not current time. This confirms the cached path is active.
  5. Run chrome.storage.local.getBytesInUse('configCache') in the worker console to confirm the cache is not growing unboundedly.

FAQ

Why does registering a listener inside an async function cause it to be silently dropped?

Chrome requires all persistent listeners to be registered during the synchronous startup phase — before the event loop yields on the first await. A listener registered inside an awaited async function may be skipped when the host marks initialization complete. Firefox is more permissive but relying on that behavior is a portability hazard.

How much does the V8 bytecode cache reduce cold-start cost?

It eliminates the re-parse phase (40–60% of total cost on large bundles) for the second and subsequent cold starts in a cache lifetime. It does not reduce top-level execution or async I/O. A 600 KB bundle costing 500 ms on first install may cost 200 ms on subsequent starts. Any change to background.js invalidates the cache.

Can I use chrome.storage.session for the config cache instead of storage.local?

Yes, but session storage is cleared when the browser closes. Use it for ephemeral warm-up flags (e.g., “rule engine already initialized this session”) and local for the durable processed config that should survive browser restarts.

Other Testing, Debugging & Performance Optimization Resources