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.
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.
Before profiling, confirm the following so measurements reflect production conditions rather than development quirks:
.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.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.chrome.storage.local.clear() before each cold-start test run to eliminate pre-warmed cache effects.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.*
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.
chrome://extensions, click Service worker to open the worker’s DevTools.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.
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.
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.
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.
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.
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.import() requires ESM module worker: add "type": "module" to the "background" entry in manifest.json; without it, dynamic imports throw a TypeError at runtime.await in the top-level script may silently fail to attach in some eviction scenarios. Register first, then await.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.