Chrome Extension Size Optimization — Developer Guide

9 min read

Extension Size Optimization

Learn how to reduce your Chrome extension’s bundle size for faster installs, better performance, and smoother Chrome Web Store reviews.

Overview

Smaller extensions offer tangible benefits beyond just saving disk space:

Target bundle sizes: background/service worker under 100KB, content scripts under 50KB.

Measuring Size

Before optimizing, you need accurate measurements of your extension’s size.

Checking Package Size

# Build your extension, then check the CRX/ZIP size
npm run build
ls -lh dist/*.zip   # or *.crx depending on your build

Bundle Analysis

Use visualization tools to understand what’s contributing to your bundle size:

# Webpack bundle analyzer
npx webpack-bundle-analyzer dist/manifest.json

# Or for esbuild
npx esbuild-visualizer --metadata=dist/.esbuild-meta.json --filename=dist/visualizer.html

These tools generate treemaps showing which dependencies and modules contribute most to size.

Chrome DevTools

Use Chrome DevTools to see actual loaded file sizes:

  1. Load your extension in chrome://extensions
  2. Enable Developer mode
  3. Click on your extension’s service worker or background page
  4. Open DevTools and check the Sources panel for file sizes

Tree Shaking

Tree shaking removes unused code from your final bundle, dramatically reducing size.

Enable ES Modules

Use ES modules with import/export for effective tree shaking:

// GOOD: Named imports enable tree shaking
import { debounce, throttle } from './utils.js';

// GOOD: Selective imports
import { formatDate } from 'date-fns';

// BAD: Namespace import prevents tree shaking
import * as utils from './utils.js';

// BAD: CommonJS prevents tree shaking
const { debounce } = require('./utils.js');

Build Tool Configuration

Modern bundlers like esbuild, webpack, and Vite support tree shaking automatically when you use ES modules:

// esbuild.config.js
export default {
  bundle: true,
  format: 'esm',        // Use ES modules
  treeShaking: true,   // Explicitly enable (default in esbuild)
  minifyWhitespace: true,
  minifyIdentifiers: true,
  minifySyntax: true,
};

Avoid CommonJS (require(), module.exports) as it prevents effective tree shaking.

Code Splitting

Code splitting separates your bundle into smaller chunks that load on-demand.

Separate Entry Points

Each extension context should have its own entry point:

// webpack.config.js - separate entry points
module.exports = {
  entry: {
    background: './src/background.ts',
    popup: './src/popup.tsx',
    options: './src/options.tsx',
    content: './src/content.ts',
  },
};

This ensures the popup doesn’t load content script code, and vice versa.

Dynamic Imports

Load features on-demand rather than at startup:

// BAD: Load everything at startup
import { HeavyChart } from './charts.js';
import { Analytics } from './analytics.js';

// GOOD: Load only when needed
async function showChart() {
  const { HeavyChart } = await import('./charts.js');
  return new HeavyChart(container);
}

// Click handler loads the module on-demand
button.addEventListener('click', showChart);

Lazy Load Extension Pages

For options pages or rarely-used features, use dynamic imports:

// content-script.ts - lazy load heavy feature
if (userClicksAdvancedFeature) {
  const { initAdvancedFeature } = await import('./advanced-feature.js');
  initAdvancedFeature();
}

Dependency Management

Dependencies often form the largest portion of extension bundles.

Audit Dependencies

Regularly review what you’re importing:

# List installed packages
npm list --depth=0

# Check for unused packages
npx depcruise --validate . | grep "unused"

Replace Heavy Libraries

Many popular libraries have lighter alternatives:

Heavy Library Lighter Alternative
moment.js date-fns or dayjs
lodash Native JS or lodash-es
axios fetch (native) or ky
underscore Native JS
// GOOD: date-fns tree-shakes to ~2KB per function
import { format, parseISO } from 'date-fns';

// GOOD: lodash-es supports tree shaking
import { debounce, throttle } from 'lodash-es';

// BAD: moment.js is ~300KB minified
import moment from 'moment';

Prefer No-Dependency Solutions

For simple tasks, write your own utilities:

// Instead of importing a library for simple tasks
// GOOD: Simple debounce implementation
function debounce(fn, delay) {
  let timeoutId;
  return (...args) => {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => fn(...args), delay);
  };
}

Bundle Dependencies

Don’t use CDN links for dependencies. Bundle everything:

<!-- BAD: External CDN (fails offline, adds latency) -->
<script src="https://cdn.example.com/library.js"></script>

<!-- GOOD: Bundled -->
<script src="library.bundle.js"></script>

Asset Optimization

Images and other assets can significantly impact extension size.

Image Compression

# Convert PNG to WebP (smaller file size)
cwebp input.png -o output.webp

# Or use sharp for batch processing
npx sharp -i "icons/*.png" -o "icons/" -f webp

Use SVG Icons

SVG icons are scalable and typically smaller than PNG equivalents:

<!-- GOOD: SVG scales to any size, ~500 bytes -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
  <path d="M8 0L0 8l8 8 8-8z"/>
</svg>

<!-- BAD: PNG at multiple sizes -->
<img src="icon16.png" width="16" height="16">
<img src="icon32.png" width="32" height="32">

Include Only Required Icon Sizes

Only include icon sizes your extension actually uses:

icons/
├── icon16.png   # Toolbar, context menus
├── icon32.png   # Toolbar @2x
├── icon48.png   # Extensions page
└── icon128.png  # Web Store listing

Remove unused icons from your icons field in manifest.json.

Remove Unused Assets

# Find unused images in your source
npx unimported

Regularly audit and remove images, fonts, or other assets you no longer use.

Minification

Minification removes unnecessary characters from code without changing functionality.

Configure Build Tools

// esbuild - built-in minification
esbuild src/background.ts --bundle --minify --outfile=dist/background.js

// webpack - TerserPlugin for JS, CssMinimizerPlugin for CSS
const TerserPlugin = require('terser-webpack-plugin');
module.exports = {
  optimization: {
    minimizer: [new TerserPlugin({ terserOptions: { compress: drop_console: true } })],
  },
};

Remove Console Logs in Production

// webpack.config.js - drop console.* calls in production
new TerserPlugin({
  terserOptions: {
    compress: {
      drop_console: true,
      drop_debugger: true,
    },
  },
});

Source Maps

Generate source maps for debugging but exclude them from the CRX package:

// webpack.config.js
module.exports = {
  devtool: 'source-map',  // Generate source maps
  // Source maps are separate .map files
};

// Exclude from CRX: they won't be bundled automatically

Content Script Size

Content scripts run on every matching page, so keeping them minimal is crucial.

Minimal Content Scripts

// GOOD: Lightweight content script
// content.ts - just inject the core functionality
import { init } from './core.js';
init();

// Lazy load heavy modules
async function onMessage(request) {
  if (request.action === 'showUI') {
    const { showUI } = await import('./heavy-ui.js');
    showUI();
  }
}

Message-Based Architecture

Keep content scripts thin and delegate heavy work to the service worker:

// content-script.ts - minimal
document.addEventListener('click', (e) => {
  if (e.target.matches('.process-this')) {
    chrome.runtime.sendMessage({
      action: 'processElement',
      data: { outerHTML: e.target.outerHTML }
    });
  }
});

Minimal CSS Injection

// Inject only the CSS you need
const style = document.createElement('style');
style.textContent = `
  .extension-feature { display: none; }
  .extension-highlight { background: yellow; }
`;
document.head.appendChild(style);

Before/After Checklist

Run through this checklist before publishing:

See Also

Part of the Chrome Extension Guide by theluckystrike. Built at zovo.one.