Building Chrome Browser Extensions: A Developer's Guide to Files, Events, and Messaging

By James Aspinwall — February 12, 2026

Chrome extensions are one of the most accessible ways to ship software that people actually use every day. They sit inside the browser, the application where most of us spend the majority of our working hours, and they can modify web pages, intercept network requests, manage tabs, store data, and communicate with external services. Despite this power, the barrier to entry is remarkably low. If you can write HTML, CSS, and JavaScript, you can build a Chrome extension. But the architecture is unusual. Extensions are not single-page applications or traditional web apps. They are a collection of loosely coupled components that run in different execution contexts and communicate through a well-defined messaging system. Understanding this architecture is the key to building extensions that work correctly and do not drive you insane during debugging.

The Manifest: manifest.json

Every Chrome extension starts with a single file: manifest.json. This is the blueprint of your extension. Chrome reads it to understand what your extension does, what permissions it needs, what files it contains, and how its components connect. Without a valid manifest, Chrome will refuse to load your extension at all.

As of 2024, all new extensions must use Manifest V3. The older Manifest V2 format is being phased out and will eventually stop working. The transition was controversial because V3 imposed significant restrictions on how extensions can intercept and modify network requests, which affected ad blockers and privacy tools. But V3 is now the standard, and there is no reason to start a new project on V2.

A minimal manifest looks like this:

{
  "manifest_version": 3,
  "name": "My Extension",
  "version": "1.0",
  "description": "A simple Chrome extension",
  "permissions": ["storage", "activeTab"],
  "action": {
    "default_popup": "popup.html",
    "default_icon": "icon.png"
  },
  "background": {
    "service_worker": "background.js"
  },
  "content_scripts": [
    {
      "matches": ["https://*.example.com/*"],
      "js": ["content.js"]
    }
  ]
}

The permissions array is critical. Chrome enforces a strict permission model. Your extension can only access APIs that it declares in the manifest. Common permissions include storage for persistent data, tabs for reading tab URLs and titles, activeTab for temporary access to the current tab when the user clicks your extension icon, alarms for scheduling periodic tasks, and notifications for desktop notifications. Some permissions like webRequest require additional host_permissions to specify which domains the extension can interact with. Users see these permissions during installation, so request only what you need. An extension that asks for access to all websites and all browsing history will scare away users who might otherwise install it.

The Background Script: Service Worker

The background script is the backbone of your extension. In Manifest V3, it runs as a service worker, which means it is event-driven and does not persist in memory. Chrome will start it when an event fires and terminate it when it has been idle for about thirty seconds. This is a major change from V2, where background scripts could run continuously as long as the browser was open. The service worker model saves memory but forces you to think differently about state management.

The background script is where you register event listeners, handle messages from other parts of the extension, interact with Chrome APIs that are not available in content scripts, and coordinate complex workflows that span multiple tabs or windows. It has full access to most Chrome extension APIs including chrome.storage, chrome.tabs, chrome.alarms, chrome.notifications, and chrome.runtime.

Because the service worker can be terminated at any time, you cannot store state in global variables and expect it to survive. Instead, use chrome.storage.local or chrome.storage.session for persistence. Session storage was introduced specifically for V3 to give extensions a place to store temporary data that survives service worker restarts but is cleared when the browser closes. This replaces the common V2 pattern of keeping state in a long-lived background page.

A typical background script registers its event listeners at the top level. This is not optional. Chrome only guarantees that event listeners registered synchronously during the initial execution of the service worker will be preserved. If you register a listener inside an async callback or a setTimeout, Chrome may not fire it after the service worker restarts.

// background.js - register listeners at top level
chrome.runtime.onInstalled.addListener((details) => {
  if (details.reason === 'install') {
    chrome.storage.local.set({ count: 0 });
  }
});

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type === 'getData') {
    chrome.storage.local.get(['count'], (result) => {
      sendResponse({ count: result.count });
    });
    return true; // keeps the message channel open for async response
  }
});

Content Scripts: Living Inside Web Pages

Content scripts are JavaScript files that Chrome injects into web pages matching the URL patterns you specify in the manifest. They run in the context of the web page, meaning they can read and modify the DOM, add elements, remove elements, listen for user interactions, and observe mutations. However, they run in an isolated world. They share the DOM with the page but have their own JavaScript execution environment. This means a content script cannot access variables or functions defined by the page’s own JavaScript, and vice versa. This isolation prevents conflicts between your extension and the websites it operates on.

Content scripts have limited access to Chrome APIs. They can use chrome.runtime for messaging, chrome.storage for data persistence, and chrome.i18n for internationalization. They cannot use chrome.tabs, chrome.alarms, or most other APIs directly. When a content script needs to perform an action that requires elevated permissions, it sends a message to the background script and lets the background script handle it.

You can inject content scripts either declaratively through the manifest or programmatically using chrome.scripting.executeScript from the background script. Declarative injection is simpler and runs automatically when the user navigates to a matching page. Programmatic injection gives you more control over timing and conditions. For example, you might inject a script only when the user clicks your extension icon, which pairs well with the activeTab permission.

// content.js - runs inside the target web page
const articles = document.querySelectorAll('article');
articles.forEach(article => {
  const readTime = Math.ceil(article.textContent.split(/\s+/).length / 200);
  const badge = document.createElement('span');
  badge.textContent = `${readTime} min read`;
  badge.style.cssText = 'background:#1f6feb;color:#fff;padding:2px 8px;border-radius:4px;font-size:12px;';
  article.querySelector('h2')?.prepend(badge);
});

The Popup: Your Extension’s Face

The popup is the small panel that appears when a user clicks your extension’s icon in the toolbar. It is a regular HTML page, specified in the manifest under action.default_popup. You can include CSS for styling and JavaScript for interactivity. The popup has access to all Chrome extension APIs, just like the background script, making it a powerful place to build user interfaces for configuration, status display, and quick actions.

The popup’s lifecycle is important to understand. It is created when the user clicks the icon and destroyed when they click away or it loses focus. Every click creates a fresh instance. This means you cannot store state in the popup’s JavaScript variables and expect it to persist. Always read state from chrome.storage when the popup opens and write state back before it closes. Some developers use the beforeunload event to save state, but a more reliable approach is to save immediately whenever the user changes something.

<!-- popup.html -->

<!DOCTYPE html>
<html>
<head>
  <style>
    body { width: 300px; padding: 16px; font-family: sans-serif; }
    button { padding: 8px 16px; cursor: pointer; }
    #count { font-size: 24px; font-weight: bold; }
  </style>
</head>
<body>
  <h3>Click Counter</h3>
  <p>Count: <span id="count">0</span></p>
  <button id="increment">Increment</button>
  <script src="popup.js"></script>
</body>
</html>

Note that Chrome extensions enforce a strict Content Security Policy. You cannot use inline JavaScript in HTML files. All JavaScript must be in separate .js files referenced via script tags. Inline event handlers like onclick="doStuff()" will not work. Use addEventListener in your script files instead.

The Options Page

If your extension has configurable settings, you can provide an options page. Declared in the manifest with "options_page": "options.html" or "options_ui": { "page": "options.html", "open_in_tab": true }, this page is accessible from the extension’s context menu in Chrome’s toolbar or from the extensions management page. It is a full HTML page where you can build forms for user preferences, API keys, feature toggles, and other configuration. Like the popup, it has full access to Chrome APIs and should persist settings using chrome.storage.sync so they synchronize across the user’s devices.

Events: The Heartbeat of an Extension

Chrome extensions are fundamentally event-driven. Understanding the key events is essential to building extensions that respond correctly to browser activity. Here are the most important events you will work with.

chrome.runtime.onInstalled fires when the extension is first installed, updated to a new version, or when Chrome itself is updated. This is the place to run one-time setup tasks: initializing default settings in storage, creating context menu items with chrome.contextMenus.create, or registering declarative content rules. The event handler receives a details object with a reason field that tells you whether this is an install, update, or Chrome update.

chrome.runtime.onStartup fires when a Chrome profile that has your extension installed starts up. Unlike onInstalled, this fires every time the browser opens, not just on first install. Use it for tasks that need to happen at browser launch, like checking for pending notifications or refreshing cached data.

chrome.tabs.onUpdated fires when a tab’s properties change, most commonly when a page finishes loading. The callback receives the tab ID, a change info object, and the full tab object. You will often check changeInfo.status === 'complete' to know when a page has fully loaded before trying to interact with it.

chrome.tabs.onActivated fires when the user switches to a different tab. It provides the tab ID and window ID of the newly active tab. This is useful for extensions that need to update their badge, popup state, or side panel based on the current tab.

chrome.webNavigation.onCompleted fires when a page navigation finishes. It is more reliable than tabs.onUpdated for detecting page loads in specific frames, including iframes. You need the webNavigation permission to use it.

chrome.alarms.onAlarm fires when a previously created alarm triggers. Alarms are the correct way to schedule periodic work in Manifest V3. You cannot use setInterval in a service worker because it will be terminated between intervals. Instead, create an alarm with chrome.alarms.create and handle it in this event listener. The minimum interval is one minute.

chrome.action.onClicked fires when the user clicks your extension icon, but only if you have not defined a popup in the manifest. If you have a popup, clicking the icon opens the popup and this event does not fire. Use this for extensions that perform a single action on click rather than showing a UI.

Messaging: How Components Talk to Each Other

Messaging is the glue that holds a Chrome extension together. Because the background script, content scripts, and popup all run in different execution contexts, they cannot share variables or call each other’s functions directly. Instead, they communicate through Chrome’s messaging APIs.

Simple one-time messages use chrome.runtime.sendMessage and chrome.runtime.onMessage. A content script or popup sends a message, and the background script receives it. The sender can include a callback or use the promise-based API to receive a response.

// Content script sending a message to background
chrome.runtime.sendMessage(
  { type: 'analyze', url: window.location.href },
  (response) => {
    console.log('Analysis result:', response.data);
  }
);

// Background script receiving and responding
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type === 'analyze') {
    performAnalysis(message.url).then(data => {
      sendResponse({ data });
    });
    return true; // CRITICAL: return true to keep channel open for async response
  }
});

The return true in the message listener is one of the most common sources of bugs in extension development. If your response is asynchronous, meaning it involves storage reads, fetch calls, or any other async operation, you must return true from the listener to tell Chrome to keep the message channel open. If you forget this, sendResponse becomes invalid before your async operation completes, and the sender receives undefined.

Messages to content scripts work slightly differently. Because content scripts run inside specific tabs, you need to target them by tab ID using chrome.tabs.sendMessage:

// Background or popup sending to a content script
chrome.tabs.sendMessage(tabId, { type: 'highlight', selector: '.target' });

Long-lived connections use chrome.runtime.connect and create a persistent port between two components. This is useful when you need to exchange multiple messages over time, like streaming data from a content script to the background. Each side gets an onMessage event on the port object and can call port.postMessage to send data. The connection stays open until one side calls port.disconnect or the component is destroyed.

// Content script opening a long-lived connection
const port = chrome.runtime.connect({ name: 'dataStream' });
port.postMessage({ type: 'start', filter: 'images' });
port.onMessage.addListener((msg) => {
  console.log('Received:', msg);
});

// Background script accepting the connection
chrome.runtime.onConnect.addListener((port) => {
  if (port.name === 'dataStream') {
    port.onMessage.addListener((msg) => {
      if (msg.type === 'start') {
        streamData(port, msg.filter);
      }
    });
  }
});

External messaging allows web pages or other extensions to communicate with your extension using chrome.runtime.sendMessage with an extension ID, or through externally_connectable in the manifest. This is how web apps integrate with companion extensions.

Storage: Persistence That Actually Works

Chrome provides four storage areas through the chrome.storage API. storage.local stores data on the local machine with a default limit of five megabytes. storage.sync synchronizes data across all Chrome instances where the user is signed in, with a limit of one hundred kilobytes. storage.session stores data only for the current browser session and is cleared when Chrome closes, with a limit of ten megabytes. storage.managed is read-only and populated by enterprise policy.

The storage API is asynchronous and supports both callbacks and promises. You can also listen for changes with chrome.storage.onChanged, which fires whenever any value changes in any storage area. This is extremely useful for keeping the popup and content scripts in sync with background state changes without explicit messaging.

// Save data
await chrome.storage.local.set({ settings: { theme: 'dark', fontSize: 14 } });

// Read data
const { settings } = await chrome.storage.local.get('settings');

// Listen for changes from any component
chrome.storage.onChanged.addListener((changes, areaName) => {
  if (changes.settings) {
    console.log('Settings changed from', changes.settings.oldValue,
                'to', changes.settings.newValue);
  }
});

Putting It All Together: The Extension Architecture

Here is how all the pieces connect in a typical extension. The user navigates to a web page. Chrome checks the manifest’s content script URL patterns and injects your content script into matching pages. The content script reads the DOM, extracts data, and sends it to the background script via chrome.runtime.sendMessage. The background script processes the data, stores results in chrome.storage, and updates the extension badge with chrome.action.setBadgeText. When the user clicks the extension icon, the popup opens, reads from chrome.storage, and displays the results. If the user changes a setting in the popup, it writes to storage, and the content script picks up the change through chrome.storage.onChanged.

This architecture may feel indirect compared to a monolithic web application, but it provides strong isolation between components, respects the principle of least privilege through the permission system, and allows the browser to manage memory efficiently by spinning up and tearing down components as needed. Once you internalize the message-passing pattern, building Chrome extensions becomes a surprisingly pleasant experience. The APIs are well documented, the debugging tools in chrome://extensions are solid, and the Chrome Web Store gives you instant distribution to over three billion Chrome users worldwide.

Start small. Build an extension that modifies one thing on one website. Get comfortable with the manifest, the content script, and the messaging pattern. Then layer on the background script, storage, and popup as your ambitions grow. The browser is the most ubiquitous application platform on earth, and extensions are your way to make it do exactly what you want.