Muhammad Atif
Command Palette

Search for a command to run...

Blog

Building Chrome Extensions with Manifest V3: A Practical Guide

Building Chrome Extensions with Manifest V3: A Practical Guide

A complete, practical guide to Chrome extension development with Manifest V3 - service workers, content scripts, the side panel API, messaging, storage and the migration traps that break real extensions.

Manifest V3 is now the only way to publish a Chrome extension. Manifest V2 has been switched off for new submissions, and existing extensions have been pushed through migration. If you are starting a browser extension today, or dragging an old one forward, MV3 is the platform you are building against.

I have shipped five extensions to the Chrome Web Store under Manifest V3 - including Grok Automation, Design.md and Gemini Watermark Remover Pro - and most of what actually costs time is not in the documentation. It is in the gap between how MV3 is described and how it behaves when a user leaves your extension open for six hours.

This guide covers the parts that matter in production: the manifest itself, the service worker lifecycle, content scripts, messaging, storage, the side panel, and the specific mistakes that will cost you a rejection or a bug report.

What actually changed in Manifest V3

The headline change is that the persistent background page is gone. In Manifest V2 you had a background page that stayed alive for the entire browser session - a normal DOM context where you could hold state in a variable and trust it to still be there later.

Manifest V3 replaces that with a service worker. The service worker has no DOM, and Chrome terminates it aggressively when it is idle. That single change is responsible for most migration pain.

The other significant changes:

  • Remote code execution is banned. You cannot load and run a script from a server. Everything executable ships inside the package.
  • webRequest blocking is replaced by declarativeNetRequest. You declare rules; Chrome enforces them. You no longer see and rewrite every request in JavaScript.
  • Host permissions are separated from API permissions and are far more visible to users at install time.
  • executeScript moved from chrome.tabs to chrome.scripting, and now takes a function or file rather than a raw code string.

Taken together these push extensions toward being declarative and stateless. That is genuinely better for users - extensions consume less memory and can do less silently - but it means you have to design differently.

The manifest file

Everything begins with manifest.json. A realistic MV3 manifest looks like this:

{
  "manifest_version": 3,
  "name": "Example Extension",
  "version": "1.0.0",
  "description": "A short, accurate description under 132 characters.",
  "permissions": ["storage", "activeTab", "scripting"],
  "host_permissions": ["https://example.com/*"],
  "background": {
    "service_worker": "background.js",
    "type": "module"
  },
  "content_scripts": [
    {
      "matches": ["https://example.com/*"],
      "js": ["content.js"],
      "run_at": "document_idle"
    }
  ],
  "action": {
    "default_popup": "popup.html",
    "default_icon": { "16": "icons/16.png", "128": "icons/128.png" }
  },
  "icons": { "16": "icons/16.png", "128": "icons/128.png" }
}

Two details are worth calling out.

"type": "module" lets you use ES module import in the service worker. It is optional, but without it you are stuck with importScripts, which is unpleasant once the extension has more than a handful of files.

The split between permissions and host_permissions matters more than it looks. Users see host permissions as "read and change your data on example.com", and reviewers scrutinise broad patterns. <all_urls> will slow your review down and will cost you installs. Request the narrowest set that works.

The service worker lifecycle

This is the part that breaks people. Your service worker is not a background page. It starts when an event fires, and Chrome shuts it down when it has been idle - in practice after roughly 30 seconds of inactivity.

That means this is broken:

// Broken: state disappears when the worker restarts.
let requestCount = 0
 
chrome.runtime.onMessage.addListener(() => {
  requestCount++
})

requestCount resets silently. The user sees a counter that goes back to zero for no visible reason.

The fix is to treat the service worker as stateless and put anything that must survive into chrome.storage:

async function increment() {
  const { requestCount = 0 } = await chrome.storage.local.get("requestCount")
  await chrome.storage.local.set({ requestCount: requestCount + 1 })
  return requestCount + 1
}

chrome.storage.local is asynchronous and survives restarts. chrome.storage.session is useful for data that should live for the browser session but never hit disk - a decoded licence check, for example.

Registering listeners at the top level

Event listeners must be registered synchronously at the top level of the worker. If you register a listener inside an async callback, the worker may restart, miss the registration, and never receive the event.

// Correct: top-level, synchronous registration.
chrome.runtime.onInstalled.addListener(handleInstalled)
chrome.alarms.onAlarm.addListener(handleAlarm)
chrome.runtime.onMessage.addListener(handleMessage)
 
async function handleAlarm(alarm) {
  // async work is fine *inside* the handler
}

Use alarms, not timers

setTimeout and setInterval do not survive worker termination. A five-minute setInterval will simply stop firing. Use chrome.alarms, which Chrome persists and which wakes the worker:

chrome.alarms.create("refresh-licence", { periodInMinutes: 60 })
 
chrome.alarms.onAlarm.addListener(async (alarm) => {
  if (alarm.name === "refresh-licence") {
    await refreshLicence()
  }
})

In Grok Automation, the whole queue is built around this idea. The extension sends prompts in bulk, which can take a long time, so nothing depends on the worker staying alive. Queue state lives in storage, an alarm wakes the worker, it processes the next item, writes the result back and lets itself be killed. Termination becomes a normal part of the cycle rather than a bug.

Content scripts

Content scripts run in the page. They can read and change the DOM, but they live in an isolated world - a separate JavaScript context. They share the DOM with the page but not variables.

So this does not work:

// content.js - `window.appConfig` here is NOT the page's appConfig
console.log(window.appConfig) // undefined

If you genuinely need page variables, you have to inject a script into the page's own context and communicate with postMessage. Do it only when necessary; it is the part reviewers look at hardest.

Keep selectors in one file

Content scripts are coupled to someone else's markup, and that markup changes without warning. The single most useful thing I do on every extension is keep every selector in one module:

// selectors.js
export const SELECTORS = {
  composer: 'div[contenteditable="true"]',
  sendButton: 'button[data-testid="send"]',
  messageList: 'main [data-message-id]',
}

When the site redesigns, the fix is one file and one release, not an afternoon of grepping. This sounds trivial. It is the difference between a ten-minute patch and a broken extension sitting in the store for a week.

Waiting for elements

Modern sites render asynchronously, so the element you want usually is not there at document_idle. A small MutationObserver helper solves this properly:

function waitForElement(selector, timeout = 10000) {
  return new Promise((resolve, reject) => {
    const existing = document.querySelector(selector)
    if (existing) return resolve(existing)
 
    const observer = new MutationObserver(() => {
      const el = document.querySelector(selector)
      if (el) {
        observer.disconnect()
        resolve(el)
      }
    })
 
    observer.observe(document.body, { childList: true, subtree: true })
 
    setTimeout(() => {
      observer.disconnect()
      reject(new Error(`Timed out waiting for ${selector}`))
    }, timeout)
  })
}

Polling with setInterval works too, but it burns CPU on pages that are already heavy.

Messaging between contexts

An extension has several isolated contexts - service worker, content scripts, popup, options page, side panel - and they talk through message passing.

// content.js
const response = await chrome.runtime.sendMessage({
  type: "SAVE_ITEM",
  payload: { url: location.href },
})
 
// background.js
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type === "SAVE_ITEM") {
    saveItem(message.payload).then((result) => sendResponse({ ok: true, result }))
    return true // keep the channel open for the async reply
  }
})

That return true is essential. Without it the channel closes as soon as the listener returns and your async sendResponse never arrives. It is the single most common messaging bug in extension code.

To message a specific tab, use chrome.tabs.sendMessage(tabId, message) instead.

I keep message types in a shared constants file and give every message a type field. Once an extension has a dozen message kinds, string literals scattered across files become impossible to refactor safely.

The side panel API

The side panel gives you persistent UI alongside the page, instead of a popup that closes the moment the user clicks away. For anything long-running, it is a much better fit.

{
  "permissions": ["sidePanel"],
  "side_panel": { "default_path": "sidepanel.html" }
}
chrome.sidePanel
  .setPanelBehavior({ openPanelOnActionClick: true })
  .catch(console.error)

The side panel is a normal web page - you can render React into it, keep component state and let the user watch progress. Grok Automation uses it precisely because a bulk queue needs somewhere stable to report progress. A popup would close on the first click and take the UI with it.

Note that the panel's own JavaScript context also goes away when the panel closes, so the queue must live in the worker and storage, not in panel state.

Storage, quotas and sync

There are three storage areas worth knowing:

  • chrome.storage.local - around 10 MB by default, unlimited with the unlimitedStorage permission. Your default choice.
  • chrome.storage.sync - synced across the user's signed-in Chrome instances, but small: roughly 100 KB total and 8 KB per item. Good for settings, wrong for data.
  • chrome.storage.session - in-memory, cleared when the browser closes. Good for secrets you do not want on disk.

A common mistake is putting cached content in sync and hitting the quota, which fails silently in ways users report as "my settings keep disappearing". Settings go in sync; everything else goes in local.

Network requests and declarativeNetRequest

MV3 removed blocking webRequest. If you need to modify or block requests, you declare rules:

{
  "id": 1,
  "priority": 1,
  "action": { "type": "block" },
  "condition": {
    "urlFilter": "||ads.example.com",
    "resourceTypes": ["script", "image"]
  }
}

Static rules ship in a JSON file referenced from the manifest. Dynamic rules are added at runtime with chrome.declarativeNetRequest.updateDynamicRules. You can still observe requests with the non-blocking webRequest API, which is enough for most extensions that just need to notice a media URL going past.

Debugging

The service worker has its own console. Open chrome://extensions, enable Developer mode, and click the service worker link on your extension's card. Errors thrown in the worker do not appear in the page console, which trips people up constantly.

Some habits that save time:

  • Keep chrome://extensions open in a pinned tab while developing.
  • After changing the manifest or worker, click reload on the card - content script changes need a page refresh too.
  • The "Errors" button on the card collects everything the extension has thrown, including errors from earlier sessions.
  • Test with the worker deliberately killed. Click "terminate" next to the service worker link and confirm the extension still works.

That last point is the highest-value test you can run. Most MV3 bugs only appear after the worker has been shut down and restarted.

A checklist before you ship

Before submitting to the Chrome Web Store, walk through this:

  1. Terminate the service worker and confirm everything still works.
  2. Remove every permission you do not use. Reviewers check, and each one costs installs.
  3. Confirm no remote code is loaded - no CDN scripts, no eval.
  4. Make sure the description is honest about what the extension does and what it collects.
  5. Test on a fresh profile with no other extensions installed.
  6. Check behaviour when offline and when the network is slow.

I have written separately about what the Chrome Web Store review process actually looks for, which covers permissions, privacy disclosures and the rejection reasons that come up most often.

Where to go next

Manifest V3 is more restrictive than V2, and that is mostly a good thing. The constraints - no remote code, declarative network rules, a worker that dies - push you toward extensions that are smaller, faster and easier for a user to trust.

The mental shift that matters is this: assume the service worker is dead. Write every feature so it works when the worker starts cold, reads its state from storage, does one unit of work and stops. Once that is your default, MV3 stops fighting you.

If you are building something that captures media from a page, I have gone deeper into that in how to build a video downloader Chrome extension. If you want to charge for your extension, monetising with Lemon Squeezy and Cloudflare Workers covers the licensing side.

You can see the extensions I have shipped on orbitexaio.com, or browse the source and other work on GitHub.

Command Palette

Search for a command to run...