← Writing
The Editorial · Engineering

Browser extensions, from an empty folder — everything Manifest V3 actually asks of you

Issue 017Jul 31, 202626 min read

Grammarly, MetaMask, your ad blocker, that one dark-mode toggle — every extension you have ever installed is a folder of HTML, CSS and JavaScript with one extra file in it. That file is manifest.json, and it is the entire difference between a web page and a program allowed to reach into other people's web pages. Here is all of it, built up from an empty folder: the four places your code can run, why the background script keeps forgetting things, what each permission costs you in installs, and what it takes to actually ship.

The issue

It is 11pm and you are looking at a pair of headphones that cost ₹18,000. That number means nothing. What you actually want to know is: how many hours do I have to work for these? Nobody is going to build that for you. But you could build it yourself this evening, in about forty lines, and it would run on the real shopping site — not a clone, not a mock, the actual page in your actual browser. That is what a browser extension is, and it is a much smaller thing than it sounds like.

Here is the whole trick. An extension is a folder of ordinary web files — HTML, CSS, JavaScript — with one extra file in it called manifest.json. The web files are nothing special; if you have ever written a page with a button on it, you have already written most of an extension. The manifest is the part that turns them into something else. Think of the browser as a shopping mall and every website as a shop inside it. Normally your JavaScript is a shopkeeper: it runs inside one shop and touches nothing outside it. An extension is a contractor with a badge — and the manifest is the badge. It lists which shops this contractor may walk into and which tools they are allowed to carry. Everything in Manifest V3 is a rule about what may be written on that badge.

Sixty seconds: the smallest extension that exists

Before any theory, make one. Create an empty folder called hello and put exactly two files in it.

hello/manifest.jsonJSON
{
  "manifest_version": 3,
  "name": "Hello",
  "version": "1.0",
  "action": { "default_popup": "popup.html" }
}
hello/popup.htmlHTML
<!doctype html>
<body style="width: 200px; padding: 14px; font: 14px system-ui">
  It works.
</body>

Now open chrome://extensions, flip on Developer mode in the top right, click Load unpacked, and choose the folder. A new tile appears. Pin it from the puzzle-piece icon in the toolbar, click it, and a little panel says It works. That is a real, installed browser extension. No build step, no bundler, no npm, no account, no compiler. If you change a file, come back to chrome://extensions and hit the circular reload arrow on the tile.

The four places your code can run

This is the single idea that unlocks everything else, and it is where almost every beginner gets stuck. An extension is not one program. It is up to four separate programs that happen to ship in the same folder, run at different times, in different worlds, with different powers — and which cannot see each other's variables at all.

where an extension's code can runtoolbarpopup.htmldies when you look awaythe web pagecontent.jsruns on the page's DOMsw.js — service workerasleep until an eventfour separate programs, four separate consoles
Fig 1Four runtimes in one folder. The content script has the page but almost no extension APIs; the service worker has every API but no page; the popup is a real web page that stops existing the instant you click away.AI-generated figure

You do not need all four. The hello-world above had only a popup. The extension we are about to build uses three, and skips the options page because the popup is enough.

The thing we are building

Call it Hours. You tell it what you earn per hour, once. From then on, every price on a shopping page gets a second number next to it: how long you have to work to pay for that. ₹18,000 headphones on a ₹500/hour wage becomes ₹18,000 · 36.0 h of work. It is a small idea, it is genuinely useful, and it happens to need almost every part of Manifest V3 — a content script to rewrite the page, storage to remember the wage, a popup to set it, messaging to connect them, and a service worker for the right-click menu.

hours/ — the whole projectText
hours/
├── manifest.json     the badge
├── content.js        runs on the shopping page
├── sw.js             the background service worker
├── popup.html        the panel behind the toolbar icon
├── popup.js
└── icons/
    ├── 16.png  48.png  128.png

manifest.json, line by line

Here is the real manifest for the whole extension. Every line in it is doing a specific job, and there is nothing hidden — if a capability is not written here, the extension does not have it.

hours/manifest.jsonJSON
{
  "manifest_version": 3,
  "name": "Hours",
  "version": "1.0.0",
  "description": "Shows every price as the hours of work it costs you.",

  "icons": { "16": "icons/16.png", "48": "icons/48.png", "128": "icons/128.png" },

  "action": {
    "default_popup": "popup.html",
    "default_title": "Set your hourly wage"
  },

  "permissions": ["storage", "contextMenus"],
  "host_permissions": ["https://www.amazon.in/*"],

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

  "content_scripts": [
    {
      "matches": ["https://www.amazon.in/*"],
      "js": ["content.js"],
      "run_at": "document_idle"
    }
  ]
}
manifest.json — the badgemanifest.jsonactiontoolbar buttoncontent_scriptscode on the pagebackgroundthe service workerpermissionschrome.* APIshost_permissionswhich sitesnothing works unless it is declared here first
Fig 2The manifest is not configuration around the code — it is the code's permission slip. Each field switches on exactly one capability, and nothing is switched on by default.AI-generated figure

A word on matches, because it is its own little language: the pattern is scheme://host/path, where the host may start with *. and the path may end with *. So https://*.amazon.in/* covers every subdomain, https://www.amazon.in/dp/* covers only product pages, and <all_urls> covers everything on the internet. That last one is legal, easy, and the single most expensive thing you can write in this file — more on that later.

One more field worth knowing before you need it: run_at. It takes document_start (before the page's own scripts, before the DOM exists — use this to inject CSS that must not flash), document_end (DOM parsed, images still loading), or document_idle, the default, which means roughly "once things have settled down". Ninety percent of the time the default is correct.

Part one: the content script

The content script is the part that touches the website. Chrome injects it into every matching page automatically — you do not call anything, you do not import anything, it simply runs. Ours finds every price in the page's text and appends the hours.

hours/content.jsJavaScript
// Runs inside amazon.in, on the real DOM, alongside the site's own scripts.
const PRICE = /₹\s?([\d,]+)/g;

async function paint() {
  const { wage } = await chrome.storage.sync.get("wage");
  if (!wage) return;                    // no wage set yet — do nothing at all

  // Walk text nodes only. Never rewrite innerHTML on someone else's page:
  // you would blow away their event listeners and break the site.
  const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
  const targets = [];
  while (walker.nextNode()) {
    const node = walker.currentNode;
    if (node.nodeValue.match(PRICE)) targets.push(node);
  }

  for (const node of targets) {
    node.nodeValue = node.nodeValue.replace(PRICE, (whole, digits) => {
      const hours = Number(digits.replace(/,/g, "")) / wage;
      return whole + " · " + hours.toFixed(1) + " h of work";
    });
  }
}

paint();

// The popup will ask us to run again when the wage changes.
chrome.runtime.onMessage.addListener((msg) => {
  if (msg.type === "REPAINT") paint();
});

Two things in there are worth stopping on. First, the TreeWalker: we edit text nodes one at a time instead of doing document.body.innerHTML = .... Rewriting the HTML of a page you do not own destroys every event listener the site attached, and the site breaks in ways the user will blame on you. Second, chrome.storage is one of the very few chrome.* APIs a content script may use. It cannot open tabs, it cannot create context menus, it cannot read history. It has the DOM and it has a phone line home — that is the deal.

Why your content script cannot see the page's variables

Sooner or later you will open a site's DevTools, see that it has a lovely global like window.cartTotal, write console.log(window.cartTotal) in your content script, and get undefined. Nothing is broken. Content scripts run in what Chrome calls an isolated world: your script and the page's scripts share the same DOM, and share nothing else.

two JS worlds, one DOMpage worldwindow.cartTotalthe site's own scriptsisolated worldyour content.jsvariables never crossthe DOM — sharedyou can rewrite the page, you cannot read its variables
Fig 3One DOM, two JavaScript worlds. Both sides see the same elements; neither can see the other's variables, functions, or patched prototypes.AI-generated figure

This is a security feature in both directions. A hostile page cannot reach into your extension and call your privileged code, and your extension cannot be broken by a site that decided to redefine Array.prototype.map. You get a clean, unpatched JavaScript environment on every page, which is worth more than the occasional inconvenience.

When you genuinely do need the page's own world — reading a framework's internal state, patching a global function — there are two doors. Modern Chrome lets you declare "world": "MAIN" on a content script entry, which drops it straight into the page's world (and gives up the isolation, permanently, for that script). The older trick is to inject a <script> tag from your isolated content script and then talk to it over window.postMessage. Both are escape hatches. Reach for them last.

Part two: the popup

The popup is the panel that drops down from your toolbar icon. It is a plain web page — HTML, CSS, a script tag — with one important quirk: it is constructed the moment it opens and demolished the moment it closes. Any variable you set in it is gone the next time the user clicks. Everything the popup wants to remember has to be written to storage.

hours/popup.htmlHTML
<!doctype html>
<meta charset="utf-8" />
<body style="width: 240px; padding: 14px; font: 14px system-ui">
  <label for="wage">What do you earn per hour?</label>
  <input id="wage" type="number" min="1" placeholder="500" />
  <button id="save">Save</button>
  <p id="status"></p>

  <!-- Must be an external file. Inline <script> is blocked in MV3. -->
  <script src="popup.js"></script>
</body>
hours/popup.jsJavaScript
const input  = document.getElementById("wage");
const status = document.getElementById("status");

// Rebuild the UI from storage every time the popup opens.
chrome.storage.sync.get("wage").then(({ wage }) => {
  if (wage) input.value = wage;
});

// onclick="..." in the HTML would also be blocked. Listeners only.
document.getElementById("save").addEventListener("click", async () => {
  const wage = Number(input.value);
  if (!wage) return;

  await chrome.storage.sync.set({ wage });
  status.textContent = "Saved.";

  // Tell the content script on the current tab to repaint right now.
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  if (tab) chrome.tabs.sendMessage(tab.id, { type: "REPAINT" });
});

Notice what we did not need for chrome.tabs.query: the "tabs" permission. Reading a tab's id is free; the "tabs" permission is only required to read a tab's url, title or favIconUrl — and it produces a scary install warning. A surprising number of extensions request it out of habit and pay for it in installs.

Storage, and why localStorage is the wrong answer

Every part of the extension needs the wage, and the parts cannot see each other. chrome.storage is the shared notebook they all write in. It comes in three areas, and choosing the wrong one is a real bug rather than a style question.

So why not localStorage? Because it is per-origin, and your four runtimes do not share an origin. The content script's localStorage is amazon.in's localStorage — you would be writing your user's salary into a shopping site's storage, where the site can read it. The popup's is your extension's. The service worker cannot use it at all, because service workers have no localStorage. One API, three different wrong behaviours. Use chrome.storage.

One more thing worth wiring up early — storage tells you when it changes, so parts of your extension can react without anyone sending a message:

anywhere in the extensionJavaScript
chrome.storage.onChanged.addListener((changes, area) => {
  if (area === "sync" && changes.wage) {
    console.log("wage went from", changes.wage.oldValue,
                "to",             changes.wage.newValue);
  }
});

Part three: the service worker, and its amnesia

This is the part of Manifest V3 that people complain about, and it is the change that everything else in MV3 orbits. In Manifest V2 you got a background page — an invisible web page that stayed open from the moment Chrome started until the moment it quit. It had a DOM, it had timers, it had variables that lasted all day. Twenty extensions installed meant twenty invisible pages sitting in memory, and that is a large part of why Chrome had a reputation for eating RAM.

MV3 replaced it with a service worker: a script with no DOM and no window, which Chrome starts when an event arrives and terminates roughly thirty seconds after things go quiet. It is not a program that runs. It is a set of handlers that get woken up.

MV2 background page vs MV3 service workerMV2always running, all dayMV3onInstalledonMessageonAlarmonMessageterminated, ~30s idleanything you kept in a variable is gone by the next event
Fig 4MV2's background page ran all day. MV3's service worker runs in bursts — a few hundred milliseconds per event, then nothing. Everything in between is gone.AI-generated figure
hours/sw.js — the real oneJavaScript
// The back office. No DOM, no window, no localStorage — and it does not stay awake.

chrome.runtime.onInstalled.addListener(async () => {
  const { wage } = await chrome.storage.sync.get("wage");
  if (!wage) await chrome.storage.sync.set({ wage: 500 });

  // Context menus can ONLY be created here. A content script cannot do this.
  chrome.contextMenus.create({
    id: "hours-check",
    title: 'How many hours is "%s"?',
    contexts: ["selection"],
  });
});

chrome.contextMenus.onClicked.addListener(async (info, tab) => {
  if (info.menuItemId !== "hours-check") return;

  const { wage } = await chrome.storage.sync.get("wage");
  const amount = Number(info.selectionText.replace(/[^\d.]/g, ""));

  chrome.tabs.sendMessage(tab.id, {
    type: "TOAST",
    text: (amount / wage).toFixed(1) + " hours of work",
  });
});

Now the mistake. This is the most common Manifest V3 bug in the world, and it looks completely reasonable:

sw.js — brokenJavaScript
let clicks = 0;                          // lives in memory...

chrome.action.onClicked.addListener(() => {
  clicks++;                              // ...and memory is gone in ~30 seconds
  console.log(clicks);                   // 1, 1, 1, 1, 1, ...
});
sw.js — fixedJavaScript
chrome.action.onClicked.addListener(async () => {
  const { clicks = 0 } = await chrome.storage.session.get("clicks");
  await chrome.storage.session.set({ clicks: clicks + 1 });
});

The rule that follows from this is absolute: the service worker owns no state. Every variable at the top of that file is scratch paper that gets thrown away between events. If something has to survive, it goes in storage. And there is a second, subtler rule that comes from the same place — listeners must be registered synchronously, at the top level, on the first tick:

sw.js — listener registrationJavaScript
// WRONG. Chrome starts the worker because a message arrived, runs this
// file, and delivers the message on the next tick — by which time we are
// still inside the .then() and nobody is listening yet.
chrome.storage.local.get("config").then(({ config }) => {
  chrome.runtime.onMessage.addListener((msg) => handler(msg, config));
});

// RIGHT. Register first, on the first tick, always.
// Do the slow work inside the handler instead.
chrome.runtime.onMessage.addListener(async (msg) => {
  const { config } = await chrome.storage.local.get("config");
  handler(msg, config);
});

Talking between the parts

Four programs, no shared memory. They communicate the way separate processes always have: by passing messages, and by agreeing on a place to leave notes.

who can talk to whompopup.jssw.jscontent.jsruntime.sendMessagetabs.sendMessagetabs.sendMessagechrome.storagethe only thing all three shareno shared memory — only messages and storage
Fig 5runtime.sendMessage goes to the extension's own pages and its service worker. tabs.sendMessage goes into a specific tab's content script. Storage is the one thing all of them can read.AI-generated figure
the whole messaging API, honestlyJavaScript
// popup or content script → service worker (and every other extension page)
chrome.runtime.sendMessage({ type: "SYNC" });

// anywhere → the content script in one specific tab
chrome.tabs.sendMessage(tabId, { type: "REPAINT" });

// receiving, on either side
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (msg.type !== "SYNC") return;

  chrome.storage.sync.get("wage").then(({ wage }) => sendResponse({ wage }));

  // THIS LINE. Without it, the channel closes the moment this function
  // returns, and sendResponse is thrown away. "return true" means
  // "I am going to answer later — hold the line open."
  return true;
});

That return true is responsible for an enormous share of "my extension randomly doesn't respond" bug reports. The listener is synchronous by default; returning true is the only way to tell Chrome you intend to call sendResponse after an await.

The other error you will meet is "Could not establish connection. Receiving end does not exist." It nearly always means exactly what it says: you sent a message to a tab that has no content script — because the URL does not match your matches pattern, or because you just reloaded the extension and the already-open tabs are still running the old, now-orphaned script. Reload the page.

Permissions are a product decision

This is the section for the founders and the PMs, and engineers should read it too, because it is the part where a one-line code change moves a business metric.

There are two separate fields and they do very different things. permissions unlocks chrome.* APIs — storage, alarms, context menus, notifications. Most of these are silent: the user is never told. host_permissions unlocks websites, and websites are what Chrome warns about, in plain frightening English, on a screen the user reads immediately before deciding whether to trust you.

what each permission makes Chrome saystorageno warningactiveTabno warninghttps://amazon.in/*read and changeyour data onamazon.in<all_urls>…on allwebsitesevery rung costs you installs — ask for the lowest one that works
Fig 6The install screen is written by Chrome, not by you, and it is written to be alarming. Each rung up this ladder measurably costs installs.AI-generated figure

There is a policy dimension here too. Chrome Web Store review asks you to justify every permission in writing, and "we might use it" is a rejection. The store also enforces a single purpose rule: an extension does one narrow, describable thing. A password manager that also blocks ads and also changes your new tab page is not one extension, it is three, and reviewers will say so.

What Manifest V3 actually changed, and why

If you read about extensions online you will find a lot of anger about MV3. It is worth understanding what the fight was about, because it explains three restrictions that otherwise look arbitrary.

The practical situation today is simple even if the argument was not: Chrome switched Manifest V2 off across 2024 and 2025, the store had already stopped accepting new V2 submissions long before that, and there is no path back. When you search for extension tutorials, check the manifest version in the first code block and close the tab if it says 2.

Where the console is

You have four programs and therefore four consoles, and finding them is the single most useful piece of trivia in this article. Nobody tells you this and everybody wastes an evening on it.

Shipping it

folder to Chrome Web Storeyour folderzip itdeveloperdashboardreviewpublished$5, once, foreverhours — or weeks if you ask for a lotthe store is the only real distribution channel on Chrome
Fig 7There is no build step and no CI requirement. The distance from a working folder to a public listing is a zip file, a form, and a wait.AI-generated figure

Zip the contents of your folder — manifest.json must sit at the root of the archive, not inside a subfolder; this is the most common upload rejection. Pay the one-time $5 developer registration fee, create an item in the Chrome Web Store developer dashboard, upload the zip, and fill in the listing: description, screenshots, category, a privacy policy if you touch user data, and a justification for every permission you requested.

For founders: what extensions are actually good for

Extensions are one of the few remaining places where a very small team can put software directly into a workflow that a very large company owns. Grammarly grew inside every text box on the internet. Honey inserted itself into other people's checkout pages and sold to PayPal for around four billion dollars. MetaMask made a browser into a wallet. Loom put a record button next to the thing you wanted to record. In every case the extension was not the product — it was the distribution, a way to be present at the exact moment of need without asking anyone's permission to be there.

The other browsers

The good news is that the format is essentially shared. Edge, Brave, Opera, Arc and Vivaldi are Chromium browsers and run your Chrome extension as-is. Firefox and Safari both support Manifest V3, with differences worth knowing before you promise anyone cross-browser support.

The shape of the whole thing

Strip away the API names and an extension is four small programs in a folder, a text file saying what they are allowed to touch, and a shared notebook they leave notes in because none of them can see each other. The content script has the page and nothing else. The service worker has everything else and no memory. The popup exists only while you are looking at it. The manifest decides which of them exist at all.

That is genuinely the whole model, and it is small enough to hold in your head. Everything difficult about Manifest V3 is a consequence of two decisions — that the background is disposable, and that the code Google reviewed is the only code that runs — and once you have designed around those two, the rest is web development you already know. Start with the hello-world folder at the top of this article. It takes a minute, and after that you are not learning extensions, you are just building one.

The reference that stays correct as this changes is Chrome's own: developer.chrome.com/docs/extensions — the Manifest file format page in particular is the exhaustive list of every field, and the Samples section is a set of small working extensions you can load unpacked in the same sixty seconds.

All issues