NotesMaking Tools

How a Chrome Extension Works: Manifest V3 Structure and Flow

What actually runs where in a Manifest V3 Chrome extension. The file layout, what manifest.json tells the browser, the isolated world between content scripts and the page, the service worker that does not stay running, messaging, and permission design — with short code examples.

Making Tools Build Note

この記事を日本語で読む

Minimal illustration of a browser window divided into two panes, with a puzzle piece resting at the center and a small document connected by a thin line

Every tool I publish under Legacy Tools is a Chrome extension. I have already written about why I chose extensions over a web service. This time I want to look inside: how a Manifest V3 extension is actually put together, and where the pieces meet.

Introductions to Chrome extensions usually say "it's just HTML and JavaScript," and that is true as far as it goes. But when I actually build one, the things that trip me up are never the files themselves. They are the boundaries between the parts. Which context is this JavaScript running in? When does the service worker stop? How far do the permissions reach? This article stays on those boundaries.

Start with the file layout

A small extension looks roughly like this:

my-extension/
├─ manifest.json
├─ background.js
├─ content.js
├─ popup/
│  ├─ popup.html
│  ├─ popup.css
│  └─ popup.js
├─ options/
│  ├─ options.html
│  └─ options.js
└─ icons/
   ├─ icon16.png
   ├─ icon48.png
   └─ icon128.png

In one line each:

manifest.json   tells Chrome the structure, permissions, and when to run what
content.js      watches and edits the DOM on web pages
background.js   handles events on the browser side
popup           the small screen behind the toolbar icon
options         the screen for longer-lived settings

The split is not cosmetic. Each piece of JavaScript runs in a different place, and each place allows different things.

What manifest.json tells Chrome

The manifest declares the extension's structure, permissions, and triggers. Here is a short example:

{
  "manifest_version": 3,
  "name": "Example Extension",
  "version": "1.0.0",
  "permissions": ["storage"],
  "host_permissions": ["https://example.com/*"],
  "background": {
    "service_worker": "background.js"
  },
  "content_scripts": [
    {
      "matches": ["https://example.com/*"],
      "js": ["content.js"]
    }
  ],
  "action": {
    "default_popup": "popup/popup.html"
  }
}

What matters is not memorizing keys but seeing how they relate:

  • permissions — access to Chrome APIs such as chrome.storage
  • host_permissions — which websites the extension may touch
  • content_scripts.matches — which pages content.js is injected into
  • background.service_worker — the file that handles events in the background
  • action.default_popup — what opens when the toolbar icon is clicked

Note that permissions and host_permissions are separate keys. Access to browser APIs and access to websites are managed as two different things — that distinction comes back later.

The boundary between content scripts and the page

The most important thing to understand about extensions is not any file name. It is where each piece of JavaScript executes.

Web page
├─ the page's own JavaScript
└─ content script
       ↓ messages
extension service worker
       ↓
chrome.storage / tabs / scripting ...

A content script can read and modify the page's DOM. But it runs in an "isolated world" — an execution environment separated from the page's own scripts. Which leads to a common surprise:

// Defined by the page's own JavaScript
window.applicationState = {
  loggedIn: true
};
// Not necessarily visible from the content script
console.log(window.applicationState);

The DOM is shared; the JavaScript global scope, in general, is not. You can click the page's buttons and read its input fields, yet the variables its scripts hold are not simply there for you. Knowing this asymmetry changes how you design a content script.

The service worker does not stay running

If you think of the Manifest V3 service worker as a resident process, your implementation will go wrong.

let currentSettings = {};

State held only in memory like this disappears whenever the service worker is shut down and started again. Chrome's public documentation describes the worker as stopping after roughly 30 seconds of inactivity and waking again when an event arrives (as of 2026-07-19).

The service worker starts when an event needs handling and may stop once the work is done. Never treat an in-memory variable as persistent state.

Anything worth keeping goes into chrome.storage:

await chrome.storage.local.set({
  enabled: true,
  targetSites: ["example.com"]
});
const settings = await chrome.storage.local.get([
  "enabled",
  "targetSites"
]);

Messaging between the parts

Because the content script and the service worker live in different execution environments, they cannot call each other's functions. They pass messages instead. Here a content script asks the service worker for settings:

// content.js
const response = await chrome.runtime.sendMessage({
  type: "GET_SETTINGS"
});
// background.js
chrome.runtime.onMessage.addListener(
  (message, sender, sendResponse) => {
    if (message.type === "GET_SETTINGS") {
      chrome.storage.local.get("enabled").then((settings) => {
        sendResponse(settings);
      });

      return true;
    }
  }
);

That return true at the end of the listener is doing real work. When sendResponse is called after asynchronous work, the listener must return a literal true to keep the message channel open. Forget it, and you get responses that sometimes arrive and sometimes silently don't — the kind of bug that is hard to trace back to its cause.

Permissions are part of the design

How you write host_permissions is a trade-off between implementation effort and how much access you ask for.

  • Request <all_urls> from the start. Least effort to build — but the install prompt shows a broad "read data on all websites" warning, and users have reason to hesitate
  • Limit the extension to specific sites. Minimal permissions — but supporting a new site means shipping an update
  • Declare optional_host_permissions and let users grant sites when they need them. Permissions stay minimal — but the granting UI and permission handling add real complexity

None of these is always right; it depends on what the tool is. What I try to keep in mind is that permissions are not just configuration. They are shown to the user, as written, at install time. They cannot be decided on implementation convenience alone.

How Legacy Tools splits the work

Simplified, what Safe Privacy Gate and Safe Attachment Check do follows the same boundaries described above:

  1. The content script detects a send button or a file attachment
  2. It reads the user's settings from chrome.storage
  3. The text or the attachment is checked inside the browser
  4. If something looks like a problem, a confirmation dialog appears
  5. The user decides whether to continue or stop

And the responsibilities line up with the boundaries:

content script
- watch DOM events
- detect input fields and attachment actions
- show the confirmation dialog

service worker
- initialize on install
- manage settings and browser events
- call Chrome APIs when needed

chrome.storage
- on/off state
- target sites
- custom rules

Even a small extension starts at the boundaries

This article covered four things: execution contexts, the service worker lifecycle, messaging, and permissions. Even the smallest extension cannot avoid these four boundaries. The upside is that once they are in place, much of the rest really is ordinary HTML and JavaScript.

I left out build tools, frameworks, and testing on purpose. Mixed in with the boundaries, they would blur the focus — if they come up, they belong in a separate article.

Tags: Chrome extensions・Manifest V3・development

← All notes