How to Boost Chrome Extension Performance by 35%: A Practical Optimization Guide

AppBooster Team · · 10 min read
Performance metrics dashboard

Earlier this year, Monzo’s engineering team published a case study that caught attention across mobile dev communities: they improved their Android app’s performance by 35% with a single configuration change — enabling R8 full mode, a code optimization flag that most developers skip by default.

The lesson wasn’t really about R8. It was about the category of wins sitting untouched in every codebase: simple, high-leverage changes that teams overlook because they feel too small to matter.

Chrome extensions have the same problem. The average extension ships with 3x more code than it needs, inefficient storage patterns, chatty message passing, and content scripts that hammer the DOM on every mutation. The good news? The fixes are straightforward — and the gains are very real.

This guide covers five optimization areas where small changes produce outsized results.


Measuring First: Know Your Baseline

Before optimizing anything, establish a baseline. You can’t know if you’ve improved 35% without a starting number.

Tools to benchmark your extension:

  • Chrome DevTools Performance tab — Profile popup startup and content script execution
  • chrome://extensions — Enable “Developer mode” and use the “Inspect views” link to open DevTools for service workers
  • performance.mark() / performance.measure() — Add instrumentation directly in your extension code
  • Chrome DevTools Memory tab — Track heap usage across tab loads

Key metrics to capture:

  • Service worker startup time (target: under 50ms)
  • Content script execution time on a typical page (target: under 100ms)
  • Bundle size per context (popup, content script, background)
  • Memory usage across 5 open tabs

With a baseline in hand, every optimization below will give you a concrete percentage improvement to report.


1. Bundle Size Reduction: The R8 Equivalent for Extensions

This is your highest-leverage starting point. Most extension developers use a bundler but leave significant optimization flags disabled.

The typical before state: A popup bundle that imports all of lodash to use _.debounce, ships unused polyfills, and includes development-only error messages in the production build.

Before:

// webpack.config.js — common mistakes
module.exports = {
  mode: 'development', // left on accidentally
  entry: './src/popup.js',
  // No tree-shaking configuration
  // No minification
};
// popup.js — importing entire libraries
import _ from 'lodash';
import moment from 'moment';

const debouncedSearch = _.debounce(search, 300);
const formatted = moment(date).format('MMM D, YYYY');

After:

// webpack.config.js — production-optimized
module.exports = {
  mode: 'production', // enables tree-shaking + minification
  entry: './src/popup.js',
  optimization: {
    usedExports: true,
    sideEffects: true,
  },
};
// popup.js — import only what you need
import debounce from 'lodash/debounce';

const debouncedSearch = debounce(search, 300);

// Replace moment with native Intl API
const formatted = new Intl.DateTimeFormat('en-US', {
  month: 'short', day: 'numeric', year: 'numeric'
}).format(new Date(date));

Measurable impact: Switching from import _ from 'lodash' to import debounce from 'lodash/debounce' alone typically removes 60-70KB from the bundle. Dropping moment for native Intl saves another 67KB.

For a deeper dive on this topic, see our guide: How to Reduce Chrome Extension Bundle Size by 60%+.


2. Service Worker Optimization: Stop Wasting Startup Time

Manifest V3 replaced background pages with service workers, which introduces a key behavioral difference: service workers can terminate between events. They spin up when needed, handle the event, then stop. This is actually a performance feature — if you use it correctly.

Most developers fight this behavior instead of working with it.

Before — keeping the service worker alive unnecessarily:

// background.js — common anti-pattern
let ws = new WebSocket('wss://api.example.com'); // Persistent connection fails anyway
let cache = {}; // Lost on termination

chrome.runtime.onInstalled.addListener(() => {
  // Heavy initialization runs every time SW restarts
  loadAllUserSettings();
  prefetchAllData();
  initializeEverything();
});

After — lean, event-driven service worker:

// background.js — efficient pattern
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  // Handle immediately, return true only if async response needed
  if (message.type === 'GET_DATA') {
    getData(message.key).then(sendResponse);
    return true; // keeps channel open for async response
  }
});

// Lazy-load expensive modules only when their events fire
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
  if (changeInfo.status !== 'complete') return;
  const { analyzeTab } = await import('./tab-analyzer.js');
  analyzeTab(tab);
});

Key rules for service worker performance:

  • Never create persistent WebSocket connections in MV3 — they won’t persist anyway
  • Use chrome.storage.session (not in-memory variables) for data that should survive brief SW restarts within a session
  • Use chrome.alarms instead of setInterval for recurring work
  • Return false from message listeners unless you actually need an async response — unnecessary return true keeps the SW alive when it doesn’t need to be

Measurable impact: Removing unnecessary persistent connection attempts and lazy-loading modules reduces service worker startup time by 40-60ms in typical extensions.


3. Content Script Performance: Stop Hammering the DOM

Content scripts run in the context of every matching page. Performance problems here affect every user on every page load — making this the highest-impact area for perceived performance.

The two biggest content script mistakes: unbounded MutationObserver callbacks and uncoordinated DOM reads/writes.

Before — naive MutationObserver:

// content.js — fires on every DOM change
const observer = new MutationObserver((mutations) => {
  // Runs on EVERY mutation — could be hundreds per second on dynamic pages
  document.querySelectorAll('.target-element').forEach(el => {
    processElement(el); // reads layout, potentially triggers reflow
    el.style.border = '2px solid red'; // forces layout recalculation
  });
});

observer.observe(document.body, {
  childList: true,
  subtree: true,
  attributes: true, // watching everything
  characterData: true, // including text changes
});

After — efficient, filtered observer:

// content.js — scoped and debounced
const processQueue = new Set();
let rafId = null;

const observer = new MutationObserver((mutations) => {
  // Filter first — only process relevant mutations
  for (const mutation of mutations) {
    for (const node of mutation.addedNodes) {
      if (node.nodeType === Node.ELEMENT_NODE && node.matches('.target-element')) {
        processQueue.add(node);
      }
    }
  }

  // Batch DOM writes in a single animation frame
  if (processQueue.size > 0 && !rafId) {
    rafId = requestAnimationFrame(() => {
      // Batch all reads first
      const measurements = [...processQueue].map(el => el.getBoundingClientRect());
      // Then batch all writes
      [...processQueue].forEach((el, i) => {
        el.style.border = '2px solid red';
      });
      processQueue.clear();
      rafId = null;
    });
  }
});

// Observe only what you need
observer.observe(document.body, {
  childList: true,
  subtree: true,
  // Not watching attributes or characterData unless needed
});

Measurable impact: On dynamic single-page applications (React, Vue, Angular apps), the unoptimized observer can fire 200+ times per second during navigation. The batched version reduces main thread blocking by 70-85% on those pages.


4. Storage Optimization: Use the Right Storage for the Job

Chrome extensions have three storage options: chrome.storage.local, chrome.storage.sync, and chrome.storage.session (added in Chrome 102). Most developers use only local for everything — a missed opportunity.

Before — every read/write is a separate call:

// Multiple individual storage operations
chrome.storage.local.set({ lastUrl: tab.url });
chrome.storage.local.set({ lastVisited: Date.now() });
chrome.storage.local.set({ pageCount: pageCount + 1 });

// Reading one key at a time
chrome.storage.local.get('userPrefs', ({ userPrefs }) => {
  chrome.storage.local.get('themeColor', ({ themeColor }) => {
    chrome.storage.local.get('fontSize', ({ fontSize }) => {
      applySettings(userPrefs, themeColor, fontSize);
    });
  });
});

After — batched operations and correct storage type:

// Batch writes into a single call
chrome.storage.local.set({
  lastUrl: tab.url,
  lastVisited: Date.now(),
  pageCount: pageCount + 1
});

// Read multiple keys in one call
chrome.storage.local.get(['userPrefs', 'themeColor', 'fontSize'], (result) => {
  applySettings(result.userPrefs, result.themeColor, result.fontSize);
});

// Use session storage for temporary data (cleared when browser closes)
// Faster than local storage, doesn't bloat persistent storage
chrome.storage.session.set({ activeTabData: data });

chrome.storage.session is particularly underused. It’s ideal for:

  • Tab-specific state that doesn’t need to persist
  • Caching API responses for the current browser session
  • Temporary authentication tokens

Measurable impact: Batching 3 separate storage writes into 1 reduces storage operation overhead by ~65%. Using session storage for temporary data also prevents local storage from growing unbounded over time.


5. Message Passing Efficiency: Reduce Cross-Context Chatter

Message passing between content scripts, service workers, and popups has overhead. Every chrome.runtime.sendMessage call involves serialization, IPC, and deserialization. Chatty messaging patterns compound quickly.

Before — requesting data one field at a time:

// content.js — multiple round trips
async function getUserContext() {
  const userId = await chrome.runtime.sendMessage({ type: 'GET_USER_ID' });
  const prefs = await chrome.runtime.sendMessage({ type: 'GET_PREFS' });
  const flags = await chrome.runtime.sendMessage({ type: 'GET_FLAGS' });
  return { userId, prefs, flags };
}

After — single batched request:

// content.js — one round trip
async function getUserContext() {
  return chrome.runtime.sendMessage({
    type: 'GET_USER_CONTEXT',
    fields: ['userId', 'prefs', 'flags']
  });
}

// background.js — handle batched request
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type === 'GET_USER_CONTEXT') {
    Promise.all([
      getUserId(),
      getPrefs(),
      getFeatureFlags()
    ]).then(([userId, prefs, flags]) => {
      sendResponse({ userId, prefs, flags });
    });
    return true;
  }
});

For high-frequency communication (like streaming data from a content script), consider using chrome.runtime.connect() to establish a long-lived port instead of repeated sendMessage calls.

Measurable impact: Reducing 3 sequential message round trips to 1 can cut communication latency by 60-70% on slower machines.


Quick Wins Reference Table

OptimizationEffortImpactPriority
Switch to mode: 'production' in bundlerLowHigh (20-40% bundle size)Do it now
Replace lodash/moment with native APIsMediumHigh (50-130KB saved)This week
Scope MutationObserver (no attributes/characterData)LowHigh (DOM perf)Do it now
Batch storage reads/writesLowMedium (65% storage ops)Do it now
Lazy-load SW modules on demandMediumMedium (40ms startup)This week
Use chrome.storage.session for temp dataLowMedium (storage health)This week
Batch message passing requestsMediumMedium (60% IPC latency)This sprint
Enable usedExports + sideEffects in bundlerLowHigh (tree-shaking)Do it now

Putting It Together: A Real-World Scenario

Consider a typical productivity extension with a popup, content script, and service worker. Before optimization:

  • Bundle size: 380KB total (popup: 180KB, content script: 200KB)
  • Service worker startup: 120ms
  • Content script execution: 85ms on a heavy SPA
  • Storage operations: 12 individual calls per page load

After applying the five optimizations above:

  • Bundle size: 210KB (45% reduction)
  • Service worker startup: 55ms (54% faster)
  • Content script execution: 22ms (74% faster)
  • Storage operations: 3 batched calls per page load (75% reduction)

That’s in the same performance ballpark as Monzo’s 35% improvement — achieved not with one change, but with five small ones that compound.


For more on specific areas covered in this guide:


Analyze Your Extension’s Performance

Not sure where your extension stands? ExtensionBooster’s extension analyzer scans your published extension and identifies performance bottlenecks, bundle size issues, and optimization opportunities — without requiring access to your source code.

It’s the fastest way to get a prioritized list of your highest-impact improvements, similar to running a Lighthouse audit but built specifically for Chrome extensions.

Analyze your extension’s performance →

The 35% improvement is waiting. You just have to look for it.

Share this article

Build better extensions with free tools

Icon generator, MV3 converter, review exporter, and more — no signup needed.

Related Articles