Performance Profiling & Optimization for MV3 Extensions

Profile and optimize Manifest V3 extensions: measure service worker cold-start cost, reduce top-level work, lazy-load modules, batch storage reads, and avoid unnecessary wakeups.

The performance failure mode most MV3 developers encounter late is a service worker that feels sluggish on first action: a toolbar click takes 300 ms to respond, a context menu fires a notification half a second after the user expects it. The root cause is almost always cold-start overhead — top-level code that runs eagerly, imports pulled in at module parse time, and storage reads that block the first meaningful operation. Fixing it requires measurement before optimization, and measurement requires knowing which tools reach inside extension contexts. This section of the testing & debugging guide covers the full profiling workflow from first flame chart to production build size checks. Start with reducing service worker cold-start latency for the highest-impact single fix.

MV3 service worker performance profile: cold start phasesTimeline showing the four phases of a service worker cold start — script parse, top-level execute, first listener callback, async I/O — and where optimization effort reduces total latency.0 ms~50 ms~120 ms~200 mstime →Script parsebundle size matters hereTop-level executeeager imports run hereListener callbackfirst event handler firesAsync I/Ostorage reads, fetch→ tree-shake, code-split→ lazy imports→ defer heavy init→ pre-warm cached config→ batch reads→ parallel Promise.all

Prerequisites checklist

Before profiling, confirm the following so measurements reflect production conditions rather than development quirks:

  • Reload the extension from the packed .crx or from a production build — unminified source files skew parse times substantially.
  • "background": { "service_worker": "background.js" } declared in manifest.json with no "type": "module" unless you intend to test ESM module workers specifically.
  • DevTools Service Worker pane open in chrome://extensions → click Service worker to attach the debugger before triggering the event.
  • "permissions": ["storage"] if the profiling code uses storage reads as part of the measured path.
  • Clear the extension’s storage via chrome.storage.local.clear() before each cold-start test run to eliminate pre-warmed cache effects.

1. Measuring cold-start cost with performance.now()

The most common mistake is measuring from DOMContentLoaded or from the first chrome.runtime.onMessage listener callback. Neither captures the parse and top-level execution phases that happen before any listener fires. The correct anchor is the very first line of your service worker module.

 1// background.ts — top of file, before any imports that carry side-effects
 2const SW_START = performance.now();
 3
 4import { initRuleEngine } from './rule-engine.js';
 5import { loadConfig }     from './config-loader.js';
 6
 7chrome.runtime.onInstalled.addListener(async () => {
 8  const config = await loadConfig();
 9  await initRuleEngine(config);
10
11  const ready = performance.now() - SW_START;
12  console.log(`[perf] cold start → install handler ready: ${ready.toFixed(1)} ms`);
13});
14
15chrome.runtime.onMessage.addListener((_msg, _sender, sendResponse) => {
16  const latency = performance.now() - SW_START;
17  console.log(`[perf] cold start → first message: ${latency.toFixed(1)} ms`);
18  sendResponse({ ok: true });
19  return false;
20});

Execution context: Service worker global scope. performance.now() is available in MV3 service workers on Chrome 88+, Firefox 109+ (MV3), and Safari 15.4+. The SW_START assignment runs synchronously before any awaited import, giving you the true start of the script evaluation phase. On Firefox, performance.now() resolution is clamped to 1 ms for fingerprinting reasons — this is sufficient for cold-start measurement but too coarse for sub-millisecond micro-benchmarks.*

2. Profiling with the Performance panel

performance.now() gives you scalar timings. The Performance panel gives you the call graph, so you can see which function consumed the most time within the parse and execute phases.

  1. Open chrome://extensions, click Service worker to open the worker’s DevTools.
  2. In the worker’s DevTools window, switch to the Performance tab.
  3. Click Record, then trigger the event that wakes the service worker (e.g. click the extension icon or send a message from the popup).
  4. Wait for the first response, then click Stop.
  5. In the resulting flame chart, look for the Evaluate Script block — this is the parse + top-level execution phase. Everything under it is synchronous cost paid on every cold start.

Long Evaluate Script blocks are almost always caused by one of three things: a large bundled dependency pulled in at the top level, a heavy synchronous initialization function called at module scope, or a deep import chain that re-exports dozens of modules. The fix for each is distinct — see the bundle size and lazy import sections below.

3. Minimizing top-level synchronous work

Top-level code runs on every cold start, including cold starts triggered by events that do not need the code that ran. An extension with a rule engine, an analytics module and a notification scheduler should not initialize all three on every wake-up.

 1// background.ts — event-driven initialization pattern
 2import { getRuleEngine } from './rule-engine.js';      // lazy getter, not eager init
 3import { getAnalytics }  from './analytics.js';        // same pattern
 4
 5chrome.declarativeNetRequest.onRuleMatchedDebug?.addListener(async (info) => {
 6  // Only now initialize the rule engine — this event won't fire on most wake-ups
 7  const engine = await getRuleEngine();
 8  await engine.recordMatch(info);
 9});
10
11chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
12  if (msg.type === 'ANALYTICS_FLUSH') {
13    // getAnalytics() imports and initialises the analytics module on demand
14    void getAnalytics().then(a => a.flush()).then(() => sendResponse({ ok: true }));
15    return true; // keep channel open
16  }
17  sendResponse({ ignored: true });
18  return false;
19});

Execution context: Service worker. getRuleEngine() and getAnalytics() are lazy getter functions that use a module-scope let variable initialized to null and resolved on first call. This pattern avoids the dynamic import() overhead on every call while still deferring the first initialization to the event that actually needs it. Firefox and Safari handle this pattern identically to Chrome.*

The critical rule: register listeners synchronously at the top level. Chrome requires that all listeners be registered before the first await in the service worker startup sequence. If a listener is registered inside an async function, it may be missed on cold starts. Move registrations to the synchronous top level and defer only the initialization of what those listeners call.

4. Lazy imports and code splitting

A bundler that outputs a single background.js file means the browser must parse and evaluate all extension code on every cold start, even code paths that are only needed once a week. Dynamic import() defers that cost to the first use.

 1// background.ts — lazy import for a heavy PDF parser
 2chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
 3  if (msg.type === 'PARSE_PDF') {
 4    // Dynamic import: browser parses pdf-lib only when this message arrives
 5    import('./pdf-processor.js')
 6      .then(({ processPdf }) => processPdf(msg.payload))
 7      .then(result => sendResponse({ result }))
 8      .catch(err => sendResponse({ error: String(err) }));
 9    return true; // async response
10  }
11  return false;
12});

Execution context: Service worker. Dynamic import() in a service worker is supported in Chrome 91+ and Firefox 89+ but requires "type": "module" in the manifest’s "background" declaration. Safari added support in Safari 16. If you cannot use ESM module workers, the equivalent pattern is a lazy-initialised module variable (let mod = null; async function getMod() { return mod ??= await initModule(); }).*

Measure the impact after splitting: use chrome.storage.session to stash the timestamp of the last dynamic import and compare that on the next ten cold starts to confirm the deferred module is not being eagerly fetched by the bundler’s chunk graph.

5. Bundle size and tree-shaking

The bundle size of background.js directly controls parse time. Every kilobyte that enters the parse phase costs approximately 0.5–1 ms on a mid-range device. For a 500 KB unminified bundle, that is 250–500 ms of pure parse overhead before a single line of your code executes.

 1// webpack.config.js or vite.config.ts — production build settings
 2{
 3  "optimization": {
 4    "usedExports": true,      // tree-shaking: removes unused exports
 5    "sideEffects": false,     // allows elimination of side-effect-free modules
 6    "splitChunks": {
 7      "chunks": "all",        // split shared code to lazy-loadable chunks
 8      "minSize": 20000        // only split chunks larger than 20 KB
 9    }
10  }
11}

Execution context: Build tool configuration — not extension runtime code. Run npx webpack-bundle-analyzer or Vite’s built-in rollup-plugin-visualizer after every significant dependency addition to verify that the background bundle has not grown past your target. A reasonable production target is under 80 KB minified and gzipped for the synchronously loaded portion of background.js.*

Check tree-shaking is actually working: import a known-unused function, build, and confirm it does not appear in the output. If it does, the package’s package.json is missing a "sideEffects": false field, or the import path triggers a module with side effects.

6. Storage read batching

A service worker that wakes to handle a message and issues three sequential chrome.storage.local.get() calls adds three serial round-trips to a synchronous critical path. Each call costs 1–5 ms, but in sequence they compound.

1// anti-pattern — three serial reads on every wake-up
2const { theme }    = await chrome.storage.local.get('theme');
3const { ruleset }  = await chrome.storage.local.get('ruleset');
4const { userData } = await chrome.storage.local.get('userData');
5
6// correct — one batched read, one IPC round-trip
7const { theme, ruleset, userData } = await chrome.storage.local.get([
8  'theme', 'ruleset', 'userData'
9]);

Execution context: Service worker or any extension page. The batched get() with an array argument resolves all keys in a single IPC call to the storage backend. For keys with predictable defaults, pass an object instead of an array: chrome.storage.local.get({ theme: 'light', ruleset: [] }) — missing keys are filled with the default values you supply, eliminating a separate null check step.*

For configuration that is read on nearly every wake-up, cache the parsed result in a module-scope variable and refresh it on chrome.storage.onChanged. This cuts storage reads to near-zero on warm restarts where the variable is still in scope, and keeps the cache coherent across contexts.

MV3 Constraints box

  • No persistent background: the service worker is evicted after ~30 seconds of idle time. Top-level code executes on every cold start — there is no concept of a persistent warm-up phase.
  • No setInterval / setTimeout for keepalive: Chrome terminates workers that use timers to defeat eviction. Use chrome.alarms for scheduled work.
  • performance.now() in workers: available but clamped to 1 ms resolution on Firefox for privacy reasons. Sufficient for cold-start measurement; not for sub-millisecond micro-benchmarks.
  • Dynamic import() requires ESM module worker: add "type": "module" to the "background" entry in manifest.json; without it, dynamic imports throw a TypeError at runtime.
  • Storage API is async-only: there is no synchronous storage read in MV3. All reads must be awaited, which means they yield the event loop. Design initialization paths to issue all reads in one batched call.
  • Event listeners must register synchronously: any listener registered after the first await in the top-level script may silently fail to attach in some eviction scenarios. Register first, then await.

Cross-browser notes

Firefox ships MV3 with the same service worker eviction model as Chrome, but Firefox’s JIT compiler typically parses equal-sized bundles 20–40% faster. This means Firefox numbers will underestimate cold-start cost on Chrome — always measure on Chrome before optimizing.

Safari enforces a shorter idle eviction window and is stricter about wakeup frequency. Extensions that try to self-wake via chrome.alarms with intervals under 1 minute are rate-limited on Safari. Battery impact is also surfaced directly to users on macOS, making unnecessary wakeups a user-visible problem.

Edge inherits Chrome’s eviction model exactly. Memory pressure evictions can happen sooner than Chrome on devices with tight RAM budgets — watch for chrome.runtime.onSuspend firing earlier than expected.