Bundling Chrome Extensions with Rollup: Tree-Shaking and Code Splitting

16 min read

Bundling Chrome Extensions with Rollup: Tree-Shaking and Code Splitting

Bundling Chrome Extensions with Rollup: Tree-Shaking and Code Splitting

Building Chrome extensions has evolved significantly over the years. As extensions become more complex, with multiple scripts, modules, and dependencies, the need for efficient bundling solutions has never been greater. Rollup, a module bundler known for its exceptional tree-shaking capabilities and optimized output, offers a powerful solution for Chrome extension developers who want to create fast, lean, and production-ready extensions.

In this comprehensive guide, we will explore how to configure Rollup specifically for Chrome extensions, leverage tree-shaking to eliminate dead code, implement code splitting for better load times, and handle the unique challenges that come with bundling extension-specific files like manifest.json, background scripts, and content scripts.


Why Use Rollup for Chrome Extensions?

When building Chrome extensions, developers often face a critical challenge: balancing functionality with performance. Extensions that load slowly or include excessive amounts of unused code frustrate users and can even be rejected from the Chrome Web Store for poor performance metrics. This is where Rollup shines.

Rollup is a next-generation JavaScript module bundler that excels at producing highly optimized bundles through aggressive dead code elimination. Unlike traditional bundlers that may include entire libraries even when you only use a small fraction of their functionality, Rollup analyzes your code at build time and includes only the code that is actually imported and executed.

For Chrome extensions, this means your final bundle can be significantly smaller than if you used other bundlers. A typical extension that might weigh 500KB with Webpack could be reduced to under 100KB with Rollup, all while maintaining the same functionality. This reduction directly translates to faster load times, better user experience, and improved battery life for users on mobile devices.

Another advantage of Rollup is its native support for ES modules. Modern JavaScript development increasingly uses ES modules, and Chrome itself fully supports them in extension contexts. By using Rollup, you can write modern code using import and export statements without worrying about compatibility issues in your final bundle.


Setting Up Rollup for Your Chrome Extension

Before diving into tree-shaking and code splitting, let’s set up a basic Rollup configuration for a Chrome extension. First, you’ll need to install Rollup and its plugins:

npm install rollup @rollup/plugin-node-resolve @rollup/plugin-commonjs @rollup/plugin-terser --save-dev

The core plugins serve essential purposes. The node-resolve plugin allows Rollup to find modules in your node_modules directory, the commonjs plugin converts CommonJS modules to ES modules (necessary for many npm packages), and the terser plugin minifies your output for production builds.

Create a rollup.config.js file in your project root:

import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import terser from '@rollup/plugin-terser';

export default {
  input: 'src/background.js',
  output: {
    file: 'dist/background.js',
    format: 'iife',
    sourcemap: true
  },
  plugins: [
    resolve(),
    commonjs(),
    terser()
  ]
};

This basic configuration takes your background script, bundles all its dependencies, and outputs an immediately-invoked function expression (IIFE) that Chrome can load directly. The output format is important because Chrome extension scripts run in their own context and need to be self-executing.

For a complete Chrome extension, you’ll likely need multiple entry points. Rollup handles this elegantly by accepting an object as the input:

export default {
  input: {
    background: 'src/background.js',
    popup: 'src/popup.js',
    content: 'src/content.js'
  },
  output: {
    dir: 'dist',
    format: 'es',
    sourcemap: true,
    entryFileNames: '[name].js',
    chunkFileNames: 'chunks/[name]-[hash].js'
  },
  plugins: [
    resolve(),
    commonjs(),
    terser()
  ]
};

This configuration creates separate output files for each entry point, making it easier to reference them in your manifest.json.


Understanding Tree-Shaking in Chrome Extensions

Tree-shaking is perhaps Rollup’s most powerful feature for extension development. It works by analyzing your static import statements and eliminating any code that is imported but never actually used. This happens through a process called static analysis, where Rollup examines your code without running it to determine which exports are referenced and which are not.

Consider a scenario where you’re building an extension that uses a utility library for various tasks. If the library contains 100 functions but you only use 5 of them, a traditional bundler might include all 100 functions in your final bundle. With Rollup’s tree-shaking, only the 5 functions you actually use will be included, potentially reducing your bundle size by 95% or more.

Here’s a practical example. Imagine you have a utility file with multiple helper functions:

// src/utils.js
export function formatDate(date) {
  return new Intl.DateTimeFormat('en-US').format(date);
}

export function formatCurrency(amount) {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD'
  }).format(amount);
}

export function calculateDiscount(price, percentage) {
  return price * (1 - percentage / 100);
}

export function logMessage(message) {
  console.log(`[Extension] ${message}`);
}

In your background script, you only import and use formatDate:

// src/background.js
import { formatDate } from './utils.js';

chrome.runtime.onInstalled.addListener(() => {
  const now = new Date();
  console.log(`Extension installed on ${formatDate(now)}`);
});

When Rollup builds this, it will analyze the imports and realize that formatCurrency, calculateDiscount, and logMessage are never used. These functions will be completely eliminated from the final bundle, resulting in a smaller output file.

To ensure tree-shaking works optimally, follow these best practices:

Use ES Modules: Tree-shaking only works with ES module imports and exports. Make sure all your source files use ES module syntax (import and export statements) rather than CommonJS (require and module.exports).

Enable Production Mode: Tree-shaking is most aggressive when building for production. The terser plugin with default settings removes unused code, but you can enhance this with additional configuration.

Avoid Dynamic Imports: Rollup can analyze static imports at build time, but dynamic imports (import() with variables) cannot be tree-shaken because the bundler cannot determine what will be loaded at build time.

Use Pure Annotations: You can add comments like /* @__PURE__ */ or /* istanbul ignore next */ to help Rollup understand code that appears unused but has side effects.


Implementing Code Splitting for Extensions

Code splitting takes the concept of optimization further by breaking your bundle into multiple chunks that can be loaded on demand. For Chrome extensions, this is particularly valuable because it allows you to defer loading code that users don’t immediately need.

Chrome extensions have specific loading behaviors that make code splitting especially useful. Background scripts load when the browser starts and stay running, popup scripts load when the user clicks the extension icon, and content scripts load when users visit matching web pages. By splitting your code appropriately, you can ensure each part of your extension loads only what it needs.

Rollup supports code splitting through dynamic imports. Instead of statically importing a module at the top of your file, you can import it conditionally:

// src/background.js - Main background script
import { initializeExtension } from './core.js';

// Initialize core functionality immediately
initializeExtension();

// Load analytics only when needed
async function loadAnalytics() {
  const { trackEvent } = await import('./analytics.js');
  trackEvent('extension_installed');
}

// Handle messages and load modules on demand
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type === 'track_action') {
    loadAnalytics().then(({ trackEvent }) => {
      trackEvent(message.action);
      sendResponse({ success: true });
    });
    return true; // Keep channel open for async response
  }
});

When Rollup encounters dynamic imports, it automatically creates separate chunk files. The main entry point becomes smaller because the analytics code is in a separate file that loads only when the user performs an action that requires it.

For content scripts, code splitting is particularly powerful because content scripts run in the context of web pages, which can have significant performance implications. By splitting your content script code, you can ensure that only essential functionality loads with the page, while advanced features load when needed:

// src/content.js
import { initCoreFeatures } from './content-core.js';

// Always load core features immediately
initCoreFeatures();

// Advanced features loaded on user interaction
document.addEventListener('click', async (event) => {
  if (event.target.classList.contains('advanced-feature-trigger')) {
    const { initAdvancedFeatures } = await import('./content-advanced.js');
    initAdvancedFeatures(event.target);
  }
}, { once: true });

This pattern significantly reduces the initial load time of your content script, improving page performance and reducing the chance of users experiencing lag when visiting websites.


Handling Manifest V3 with Rollup

Chrome’s Manifest V3 introduces specific requirements that affect how you bundle extensions. The most significant change is the transition from background pages to service workers. Service workers are event-driven and cannot maintain state between events, which has implications for how you structure your code.

When bundling for Manifest V3, ensure your output is compatible with service worker limitations:

// rollup.config.js for Manifest V3
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import terser from '@rollup/plugin-terser';
import { readFileSync, writeFileSync } from 'fs';

export default {
  input: {
    background: 'src/background/service-worker.js',
    popup: 'src/popup/popup.js',
    options: 'src/options/options.js'
  },
  output: {
    dir: 'dist',
    format: 'es',
    sourcemap: true,
    entryFileNames: '[name].js',
    chunkFileNames: 'chunks/[name]-[hash].js'
  },
  plugins: [
    resolve({
      browser: true
    }),
    commonjs(),
    terser(),
    {
      name: 'copy-manifest',
      writeBundle() {
        const manifest = JSON.parse(readFileSync('src/manifest.json', 'utf8'));
        // Update manifest with correct script paths
        manifest.background.service_worker = 'background.js';
        writeFileSync('dist/manifest.json', JSON.stringify(manifest, null, 2));
      }
    }
  ]
};

This configuration demonstrates an important pattern: keeping your source manifest separate from your distribution manifest. The custom plugin copies and updates the manifest during the build process, ensuring the paths point to your bundled output files.

For Manifest V3, remember that service workers have a maximum runtime of about 30 seconds before being terminated. Any persistent state must be stored in chrome.storage or IndexedDB. When bundling, ensure your service worker code doesn’t rely on in-memory state persisting between events.


Optimizing Bundle Size with Advanced Rollup Configuration

Beyond basic tree-shaking, several advanced techniques can further optimize your Chrome extension bundles:

Tree-Shake Lodash Functions: Instead of importing the entire Lodash library, use lodash-es or individual function packages:

// Instead of this:
import _ from 'lodash';

// Do this:
import map from 'lodash/map';
import filter from 'lodash/filter';
// Or use lodash-es for better tree-shaking:
import { map, filter } from 'lodash-es';

Configure Terser for Maximum Compression:

import terser from '@rollup/plugin-terser';

terser({
  compress: {
    passes: 2,
    pure_getters: true,
    unsafe: true,
    unsafe_comps: true,
    unsafe_math: true
  },
  mangle: {
    properties: {
      regex: /^_/
    }
  }
})

Use Module Preserve Comments: Keep important comments for debugging while removing others:

export default {
  // ... other config
  output: {
    banner: '/*! My Chrome Extension v1.0.0 */',
    footer: '/* Built with Rollup */'
  }
};

Common Pitfalls and How to Avoid Them

When bundling Chrome extensions with Rollup, developers often encounter several common issues. Understanding these pitfalls will save you hours of debugging.

Extension Context Isolation: Chrome extensions run in isolated worlds, which means your bundled code must be self-contained. Avoid relying on global variables from the host page. Rollup’s output should not depend on any external scripts being loaded.

Content Security Policy: Manifest V3 imposes strict CSP requirements. If you’re using inline scripts, you’ll need to refactor them or use external files. Rollup outputs code that can be easily loaded from external files, which aligns well with CSP requirements.

Circular Dependencies: While JavaScript supports circular dependencies, they can cause issues with tree-shaking. If module A imports B and B imports A, Rollup might include more code than necessary. Audit your dependency graph and break circular references where possible.

Missing Polyfills: Chrome extensions run in Chromium, which has modern JavaScript support, but users might use older Chrome versions. If you need to support older browsers, you may need to include polyfills in your bundle.


Building a Production-Ready Extension with Rollup

Putting it all together, here’s a comprehensive configuration for a production-ready Chrome extension:

// rollup.config.js
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import terser from '@rollup/plugin-terser';
import { readFileSync, writeFileSync, mkdirSync, cpSync, existsSync } from 'fs';

// Ensure dist directory exists
if (!existsSync('dist')) {
  mkdirSync('dist');
}

// Copy static files to dist
cpSync('src/manifest.json', 'dist/manifest.json');
cpSync('src/popup', 'dist/popup', { recursive: true });
cpSync('src/icons', 'dist/icons', { recursive: true });

export default {
  input: {
    'background/service-worker': 'src/background/service-worker.js',
    'popup/popup': 'src/popup/popup.js',
    'options/options': 'src/options/options.js',
    'content/content': 'src/content/content.js'
  },
  output: {
    dir: 'dist',
    format: 'es',
    sourcemap: false, // Disable sourcemaps for production
    entryFileNames: '[name].js',
    chunkFileNames: 'chunks/[name]-[hash].js',
    manualChunks: {
      'vendor': ['react', 'react-dom'],
      'utils': ['./src/utils/index.js']
    }
  },
  plugins: [
    resolve({
      browser: true,
      preferBuiltins: false
    }),
    commonjs(),
    terser({
      compress: {
        passes: 2,
        drop_console: true,
        drop_debugger: true
      }
    }),
    {
      name: 'update-manifest',
      writeBundle() {
        const manifest = JSON.parse(readFileSync('src/manifest.json', 'utf8'));
        manifest.background.service_worker = 'background/service-worker.js';
        writeFileSync('dist/manifest.json', JSON.stringify(manifest, null, 2));
      }
    }
  ]
};

This configuration handles multiple entry points, separates vendor code into its own chunk (which can be cached separately), removes console statements in production, and automatically updates your manifest to point to the bundled files.


Conclusion

Rollup provides Chrome extension developers with a powerful toolset for creating optimized, production-ready extensions. Through tree-shaking, you can dramatically reduce bundle sizes by eliminating unused code. Code splitting allows you to load functionality on demand, improving initial load times and user experience. Proper configuration for Manifest V3 ensures your extensions meet Google’s latest requirements while maintaining excellent performance.

The key to success lies in understanding how Rollup’s static analysis works, using ES modules throughout your codebase, and structuring your extension to take advantage of code splitting patterns. By following the patterns and configurations outlined in this guide, you’ll be well-equipped to build Chrome extensions that are fast, efficient, and ready for the Chrome Web Store.

Start by setting up Rollup in your existing extension project, run a before-and-after bundle size comparison, and you’ll likely be surprised at how much you can save. Your users will thank you with faster load times, better battery life, and a more responsive browsing experience.

No previous article
No next article