Quick Answer

A browser extension is really three isolated programs stitched together by a manifest.json file: a background service worker that runs with elevated permissions, content scripts injected into web pages to read or modify the DOM, and a permissions system that gates what each piece can touch. None of them can call each other's code directly — they communicate only by passing messages.

A Browser Extension Is Three Programs Wearing One Coat

The mental model most people have of a browser extension — "a bit of JavaScript that runs on a page" — is wrong in a way that matters. A real extension is actually up to three separate JavaScript environments that don't share memory or variables, glued together by a single manifest.json file that declares what each piece is allowed to do: a background service worker that runs extension-wide logic and has access to privileged browser APIs, one or more content scripts that get injected into specific web pages to read or modify the DOM, and optionally a popup or options page for the UI you see when you click the extension's toolbar icon.

These three pieces are deliberately isolated from each other and from the page they're running alongside. That isolation is not an accident or an oversight — it's the entire security model. A content script sitting on your bank's login page needs to be able to read the page's DOM (to, say, autofill a password), but it must not be able to see variables or functions the bank's own JavaScript defined, and the bank's JavaScript must not be able to see or call into the extension. Since the three pieces can't call each other's functions directly, they communicate the only way isolated processes can: by passing messages back and forth.

manifest.json: The Extension's Table of Contents and Its Permission Grant

Every extension starts with a manifest.json that declares its name, version, which script is the background service worker, which pages content scripts should be injected into, and which permissions it's asking the browser for. Here's a realistic, minimal one for a price-tracking extension:

{
  "manifest_version": 3,
  "name": "Example Price Tracker",
  "version": "1.0",
  "action": {
    "default_popup": "popup.html"
  },
  "background": {
    "service_worker": "background.js"
  },
  "content_scripts": [
    {
      "matches": ["https://*.amazon.in/*"],
      "js": ["content.js"],
      "run_at": "document_idle"
    }
  ],
  "permissions": ["storage", "alarms"],
  "host_permissions": ["https://api.example.com/*"]
}

Two details here are easy to get wrong and worth being precise about, since Chrome's official extension documentation calls both out specifically. First, permissions and host_permissions are deliberately separate manifest keys, not one combined list — permissions grants access to specific browser APIs like storage or alarms, while host_permissions grants access to specific websites' data. Splitting them lets the Chrome Web Store review process and the browser's own permission-request UI show users exactly which sites an extension can read, separately from which APIs it can call — which matters a lot when you're deciding whether to trust an extension that's asking for access to every website you visit. Second, action is the current unified key for the toolbar button and its popup; it replaced the older, separately-named browser_action and page_action keys that existed under the previous manifest format.

Content Scripts: DOM Access Without JavaScript Access

A content script is the only piece of an extension that runs directly in the context of a webpage, matched against URL patterns you declare in the manifest (like https://*.amazon.in/* above). This is where the "isolated world" concept matters: a content script shares the live DOM with the page — if it edits document.body, the change is real and visible — but it does not share the page's JavaScript execution context. If the page defines window.myAppState = {...}, the content script cannot see myAppState, and if the content script defines its own global variable, the page's own scripts can't see it either. Chrome's developer documentation is explicit that content scripts cannot access the page's JavaScript scope even though they share its DOM — this is what stops a malicious webpage from reaching into an extension's internals, and what stops an extension from accidentally colliding with a page's own global variables.

Content scripts also control when they get injected relative to page load, via the manifest's run_at field: document_start injects before any of the page's own scripts run (useful for ad blockers that need to intercept requests before content loads), document_end injects once the DOM is parsed but before images and subframes finish, and document_idle — the default — waits until the browser considers the page reasonably settled. Because a content script can't call privileged extension APIs like chrome.storage directly even with the right permissions declared, anything it needs done beyond touching the DOM has to be handed off to the background service worker over a message.

Background Service Workers: No More Always-On Background Page

Manifest V3's single biggest architectural change was replacing the old persistent background page with a background service worker — the same kind of service worker web apps use for offline support and push notifications, repurposed as an extension's event-driven backend. The old background page in Manifest V2 could stay loaded in memory indefinitely, holding state in ordinary JavaScript variables for as long as the browser was open, which was convenient but meant every installed extension could tie up memory and CPU forever even while doing nothing.

A service worker is fundamentally different: it starts up to handle an event (a message, an alarm, a webRequest callback), does its work, and then the browser is free to shut it down when it's been idle. According to Chrome's own extension service worker lifecycle documentation, the browser terminates an idle service worker after roughly 30 seconds of inactivity, and separately caps any single request at about 5 minutes of processing time and any fetch() call at about 30 seconds waiting for a response. Any event or extension API call resets the idle timer and can wake a terminated worker back up. The practical consequence for anyone writing extension code: you cannot rely on an ordinary JavaScript variable to hold state between events the way the old background page let you, because the worker running that variable might simply not exist anymore by the time the next event arrives. State that needs to survive has to go into chrome.storage instead, which persists independently of whether the service worker is currently alive.

How The Isolated Pieces Actually Talk To Each Other

Since a content script, a background service worker, and a popup can't call each other's functions or share variables, Chrome exposes a small messaging API that all of them can use: chrome.runtime.sendMessage to send a one-off message, and chrome.runtime.onMessage.addListener to receive one. Here's the price-tracker example actually wired up:

// content.js — running inside the amazon.in page, isolated from the page's own JS
const price = document.querySelector(".price").textContent;
chrome.runtime.sendMessage({ type: "PRICE_FOUND", price });

// background.js — the service worker, has no direct access to the page DOM
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type === "PRICE_FOUND") {
    chrome.storage.local.set({ lastPrice: message.price });
  }
});

The content script finds the price on the page (something only it can do, since only it has DOM access) and hands the value off as a message. The service worker receives that message and does something the content script itself isn't allowed to do directly — persist it via chrome.storage. For back-and-forth conversations that need to stay open rather than firing once, there's also chrome.runtime.connect, which opens a long-lived Port object both sides can keep sending messages through — useful for something like a streaming log viewer where the background worker keeps pushing updates to a popup that's open.

The Permissions Model: Ask For Only What You Need, When You Need It

Because an extension with broad host permissions can, in principle, read or alter anything on every site you visit, the permissions system is the actual security boundary users rely on, and it's worth understanding its finer-grained options beyond just listing sites in host_permissions. The activeTab permission grants temporary access to only the currently active tab, and only in direct response to the user clicking the extension's toolbar icon — it's how a "take a screenshot of this page" extension can work without ever asking for permanent access to every website. optional_permissions lets an extension declare permissions it might need later but doesn't request at install time, prompting the user only if and when a feature that actually needs them gets used.

Manifest V3 also tightened the extension's content security policy considerably compared to V2 — in particular, it disallows loading and executing remotely hosted JavaScript. All the code an extension runs has to be bundled into the package that was reviewed and installed; it can't fetch a script from a CDN and eval() it at runtime the way some older extensions did. This closes off a real historical attack pattern where an extension that looked clean at review time could later start pulling down and running attacker-controlled code after the fact.

Frequently Asked Questions

Why did Chrome move extensions from Manifest V2 to Manifest V3? The main drivers were replacing the always-resident background page (a persistent memory and battery cost) with an event-driven service worker, and tightening the security model — particularly banning remotely hosted, dynamically executed code — to reduce how much a compromised or malicious extension could get away with after being installed.
Can a content script read variables defined by the webpage's own JavaScript? No. A content script runs in an isolated JavaScript world that shares the page's live DOM but not its JavaScript execution context, so it cannot see the page's global variables or functions, and the page can't see the content script's either.
Why does my extension's background logic seem to stop working after the browser has been idle? The background service worker gets terminated by the browser after about 30 seconds without an event or API call, so any state kept only in a plain JavaScript variable is lost; that state needs to be persisted with chrome.storage instead of relying on the worker staying alive.
What's the actual difference between permissions and host_permissions in manifest.json? permissions grants access to specific browser APIs such as storage or alarms, while host_permissions separately grants access to read or modify data on specific websites — splitting them lets the browser show users precisely which sites an extension can touch, independent of which internal APIs it uses.
Do Firefox and other browsers use the same Manifest V3 model as Chrome? Firefox and other Chromium-based browsers have adopted Manifest V3 with largely compatible manifest structures and APIs, though implementation details such as service worker behavior and some API availability can differ slightly between browsers, so cross-browser extensions still need testing on each target.