Skip to main content

How Chrome Extensions Work, for Dummies

· 17 min read
Pere Pages
Software Engineer
A browser window with a small puzzle piece being plugged into its toolbar

Chrome extensions are just web pages with superpowers — but the superpowers come with rules. This is the beginner's map: the parts, the permissions, why your popup can't see the page you're looking at, and the caveats that bite everyone exactly once.

The mental model: several small worlds, not one program

If you know HyperText Markup Language (HTML), Cascading Style Sheets (CSS), and JavaScript (JS), you already know how to write an extension. What trips people up is how it runs. An extension is not one program: it is several small JavaScript programs, each locked in its own separate world, that can only talk to each other by sending messages.

There is the popup — the little panel that opens when you click the extension's icon in the toolbar. There is the service worker — an invisible background script that reacts to events. There is the content script — the only piece of your code that gets injected into the web page in the current tab. And there is an optional options page for settings. None of them share variables. None of them can call each other's functions. They live in different worlds and pass notes under the door.

Hold on to that picture — the boxes and the dotted lines. Every confusing thing about extensions ("why is my variable undefined?", "why can't the popup read the page?") is one of those boxes assuming it lives in another box. The rest of this post walks the picture top-to-bottom: first the file that declares all these parts, then each part in turn, then the permission system that decides what they may touch, and finally the caveats.

The manifest: your contract with the browser

Every extension is a folder, and at the root of that folder sits manifest.json — a JavaScript Object Notation (JSON) file that tells Chrome what your extension is, which parts it has, and what it wants permission to do. Nothing happens in an extension unless the manifest declares it first. The current (and now mandatory) format is Manifest V3 (MV3); everything in this post is MV3.

A realistic small manifest looks like this:

{
"manifest_version": 3,
"name": "My First Extension",
"version": "1.0",
"description": "Does one small useful thing.",

"action": {
"default_popup": "popup.html"
},

"background": {
"service_worker": "background.js"
},

"content_scripts": [
{
"matches": ["https://example.com/*"],
"js": ["content.js"]
}
],

"permissions": ["storage"],
"host_permissions": ["https://example.com/*"]
}

Reading it top to bottom: action gives you the toolbar icon and names the popup's HTML file; background names the service worker script; content_scripts says "inject content.js into every page whose Uniform Resource Locator (URL) matches this pattern"; and the two permission keys — which get their own section below — say what you're allowed to touch. Delete a key and that part of your extension simply doesn't exist.

The four places your code runs

Now the boxes themselves. The manifest above named four different JavaScript files, and each runs somewhere different, with a different slice of Chrome's extension application programming interface (API) — the chrome.* object — available to it. Each context trades power for access: the ones with full access to the chrome.* API cannot touch the web page, and the one that can touch the web page can barely use the API.

ContextWhat it really isCan touch the page's DOMFull chrome.* APILifetime
PopupA tiny standalone HTML pageNoYesOnly while open — closes on any click outside
Content scriptYour JS injected into the tabYesTiny subsetAs long as the page lives
Service workerA background event handlerNoYesSeconds — killed after ~30 s idle
Options pageA settings HTML pageNoYesOnly while open

yes / full good partial no / minimal

The popup is just an HTML page — your own HTML, CSS, and JS, rendered in that small floating panel. It can use the full extension API, but it is not inside the tab: document in popup code means the popup's own tiny document, not the page you're looking at.

The content script is the opposite. It runs inside the tab and can read and rewrite the page's Document Object Model (DOM) — hide elements, inject banners, scrape text. But it gets only a sliver of the extension API: essentially chrome.runtime messaging and chrome.storage. It cannot open tabs, cannot touch bookmarks, cannot do most chrome.* things.

The service worker is the coordinator. It has no window and no DOM at all — it wakes up when an event fires (the extension was installed, a message arrived, an alarm rang), handles it, and is killed again. Chrome terminates it after roughly 30 seconds of inactivity, which has consequences we'll hit in the caveats.

The options page is just another standalone HTML page like the popup, opened from the extension's entry in chrome://extensions. Same powers, same isolation — nothing new to learn.

The dual system: the popup versus the page in the tab

This is the single biggest beginner confusion, so it gets its own section. You click your extension's icon, the popup opens in front of a web page, and it feels like the popup is "on" that page. It isn't. The popup and the page in the tab are two different documents in two different worlds — the popup can never reach into the tab directly, only by sending a message or injecting a script.

Here's the scene — simulated, like every browser screenshot in this post, so the details stay legible:

Simulated browser window with the Word Counter popup open in front of an article, with labels marking the page's document and the popup's own separate document

So when popup code runs document.querySelector('h1'), it searches the popup's own few-lines-long document and finds nothing. To act on the page the user is looking at, the popup must find the active tab and then either message a content script that's already there, or inject one. Here's the message-passing version, end to end:

And there's a second layer of separation inside the tab. Your content script shares the DOM with the page, but not the page's JavaScript. Chrome runs content scripts in an isolated world: a private JavaScript environment with its own variables and functions. The page can't see your script (good — the page can't tamper with your extension), and you can't see the page's (occasionally annoying — you can't just call window.jQuery or read the page app's state). You both see the same DOM, and that's the whole shared surface.

Two walls, then: popup ↔ tab (crossed only by messages or injection), and page scripts ↔ content script (crossed only through the DOM). Once those two walls are on your mental map, the next question is obvious — what is your code allowed to do once it's in the right place? That's permissions.

Permissions: asking before touching

By default an extension can do almost nothing interesting. Every sensitive capability must be declared in the manifest, and Chrome shows the user a warning at install time for the scary ones. There are three keys to know:

  • permissions — capability permissions, from a fixed list of strings: "storage", "tabs", "scripting", "bookmarks", "notifications", and so on. Each unlocks a chrome.* API (or part of one).
  • host_permissions — URL match patterns ("https://example.com/*", or the everything-pattern "<all_urls>") that grant access to page content: injecting scripts, reading a tab's URL, intercepting its requests.
  • optional_permissions / optional_host_permissions — the same things, but requested at runtime with a user prompt instead of at install time.

The one to remember is a special string in the first list: activeTab. activeTab is the polite default: it grants full access to the one tab the user is looking at, only after they deliberately invoke your extension, and it triggers no install warning at all. When the user clicks your icon (or uses your keyboard shortcut), Chrome temporarily treats that one tab as if you had a host permission for it — you can inject scripts and read its URL — and the grant evaporates when they navigate away.

Here's how the common choices compare, including the warning text the user sees:

You declareWhat it unlocksInstall-time warning
"storage"chrome.storageNone
"activeTab"The current tab, after a click on your iconNone
"scripting"chrome.scripting (still needs host access per site)None
"tabs"URL, title & favicon of all tabs in tabs.query"Read your browsing history"
host_permissions: ["https://example.com/*"]Inject into & read one site, automatically"Read and change your data on example.com"
host_permissions: ["<all_urls>"]Inject into & read every site, automatically"Read and change all your data on all websites"

That last warning is what the user actually stares at before clicking:

Simulated Chrome install dialog asking to add an extension that can read and change all your data on all websites

Two non-obvious rows there. First, plain chrome.tabs calls need no permission at all — the "tabs" permission only adds the sensitive fields (URL, title, favicon) to what tabs.query returns, which is why it carries the browsing-history warning. Second, "scripting" alone injects into nothing — it's the API permission, and it needs activeTab or a host permission to supply the target.

The same pattern repeats for every other capability: declare the permission, unlock the API. "cookies", for instance, plus a host permission, lets an extension read and write the browser's cookie jar through chrome.cookies — what those cookies actually are and how Chrome manages them is a story of its own, told in Inside the Cookie Jar.

Choosing is mostly a one-question flowchart:

The chrome.* API in four bites

With the map and the permissions sorted, the API itself is the easy part. It lives on the global chrome object, and modern Chrome returns promises, so you can await everything. Four calls — tabs.query, scripting.executeScript, storage, and sendMessage — cover ninety percent of what beginner extensions do.

Find the active tab — chrome.tabs.query

Almost every popup starts with this line: "which tab is the user looking at?"

const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
console.log(tab.id); // tab.url is only present with "tabs", "activeTab" or a host permission

Run code inside the page — chrome.scripting.executeScript

The injection route across the popup↔tab wall — the alternative to declaring a content_scripts entry in the manifest. The function you pass is serialized and executed inside the tab's isolated world, and its return value comes back to you:

const [{ result }] = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => document.querySelectorAll('img').length, // runs in the page, not here
});
console.log(`The page has ${result} images`);

Needs "scripting" plus "activeTab" (or a host permission) — see chrome.scripting.

Remember things — chrome.storage

The popup dies when it closes and the service worker dies when idle, so chrome.storage is where state actually lives. Use sync for small settings (they follow the user's Google account across machines), local for bigger data. Don't reach for localStorage: it isn't shared across your extension's contexts, and the service worker can't use it at all.

await chrome.storage.sync.set({ theme: 'dark' });
const { theme } = await chrome.storage.sync.get('theme');

Pass notes between worlds — chrome.runtime.sendMessage / onMessage

The messaging glue from the sequence diagram earlier. One side sends, the other listens; chrome.tabs.sendMessage targets a specific tab's content script, chrome.runtime.sendMessage reaches the extension's own pages and service worker:

// popup.js — ask the content script in this tab
const reply = await chrome.tabs.sendMessage(tab.id, { type: 'COUNT_WORDS' });
console.log(reply.words);
// content.js — answer
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'COUNT_WORDS') {
sendResponse({ words: document.body.innerText.trim().split(/\s+/).length });
}
});

A complete extension in three small files

Time to assemble the bites into a working whole: a word counter for the current page, using the polite activeTab + scripting combo — no content script file, no service worker, no install warning.

manifest.json:

{
"manifest_version": 3,
"name": "Word Counter",
"version": "1.0",
"description": "Counts the words on the current page.",
"action": { "default_popup": "popup.html" },
"permissions": ["activeTab", "scripting"]
}

popup.html — note the script is a separate file; MV3's Content Security Policy (CSP) forbids inline <script> tags:

<!doctype html>
<html>
<body>
<button id="count">Count words</button>
<p id="result"></p>
<script src="popup.js"></script>
</body>
</html>

popup.js:

document.getElementById('count').addEventListener('click', async () => {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
const [{ result }] = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => document.body.innerText.trim().split(/\s+/).length,
});
document.getElementById('result').textContent = `${result} words`;
});
Simulated close-up of the Word Counter popup showing the Count words button and a result of 1234 words

That's the entire extension. To run it:

  1. Put the three files in one folder.
  2. Open chrome://extensions and switch on Developer mode (top right).
  3. Click Load unpacked and pick the folder.
  4. Open any article, click the new toolbar icon, click Count words.
Simulated chrome://extensions page with Developer mode switched on, the Load unpacked button, and the Word Counter extension card

After edits, hit the reload icon on the extension's card (and reload the tab if you're testing content scripts). The official Get started tutorials continue from exactly this point.

The caveats nobody tells you about

Everything above is the happy path. These are the sharp edges — each one traces back to the worlds-and-walls model, and each one costs an evening of debugging the first time.

  • The service worker is ephemeral. Chrome kills it after ~30 seconds idle and restarts it on the next event, so any global variable you set will silently be gone later. Assume the service worker is already dead: keep no state in globals, and persist anything you care about in chrome.storage. Timers are the classic victim — setTimeout dies with the worker; use chrome.alarms instead.
  • Popup state evaporates. The popup is destroyed the moment it loses focus — a click on the page, an alt-tab — and reborn from scratch next open. It also cannot be opened programmatically. Same medicine: state in chrome.storage, popup as a dumb view of it.
  • The isolated world cuts both ways. Your content script cannot call the page's JavaScript, read its framework state, or use its libraries — the DOM is the only shared surface. If the page renders late (a single-page app), your script may also run before the content exists; watch the DOM with MutationObserver rather than assuming it's there.
  • Some pages are off-limits, period. Content scripts and executeScript don't work on chrome:// pages, the Chrome Web Store, or other extensions' pages — no permission unlocks them. If your extension "does nothing", check which page you're testing on: a fresh new-tab page is a chrome:// page.
  • MV3 forbids remotely hosted code. All JavaScript must ship inside the extension package — no loading scripts from a content delivery network at runtime, and (per the CSP you met above) no inline scripts or eval. Fetching data from the network is fine; fetching code is not.

Wrap-up

The mental model is genuinely the whole trick. An extension is a folder with a manifest and a handful of small JavaScript worlds: a popup that is its own page, a content script that lives in the tab but behind glass, and a throwaway service worker in the background — held together by messages, chrome.storage, and whatever the permissions you declared allow. Start with activeTab and the three-file word counter, break it, and the error messages will finally make sense — because you'll know which world they're coming from.

References