Web Development · Browser Extensions
Building Chrome Extensions in 2026: A Practical Manifest V3 Guide
Manifest V3 changed how Chrome extensions work. Here's what actually matters for developers building extensions today: service workers, declarativeNetRequest, and the gotchas nobody warns you about.
Anurag Verma
11 min read
Sponsored
Chrome extensions have been a quiet revenue source for agencies and independent developers for years. A tight productivity tool, a client-specific browser integration, a data scraper for internal use. The category is alive. But the ground shifted in 2023 when Google fully deprecated Manifest V2 and required all new extensions to use Manifest V3.
MV3 is not MV2 with a version bump. The architecture changed in ways that break several common patterns. If you’re building an extension today or migrating an old one, knowing what changed will save you a week of confused debugging.
What Actually Changed in MV3
The most significant change: background pages are gone. MV2 extensions could run a persistent background page (a long-lived HTML page with its own JavaScript context that stayed alive as long as the extension was installed). It was convenient. You could store state in memory, keep WebSocket connections open, run timers indefinitely.
MV3 replaced persistent background pages with service workers. Service workers are ephemeral. Chrome starts them when needed and terminates them after they’ve been idle for a few seconds. Your service worker will be killed mid-run if it hasn’t done anything recently.
This has real consequences:
- No persistent in-memory state. Anything you store in a
letvariable in the service worker is gone when Chrome terminates it. Usechrome.storage.localfor anything that needs to survive. - No long-lived connections from the service worker. WebSocket connections die when the service worker sleeps. Move persistent connections to content scripts where possible.
- No
setIntervalthat actually keeps running. Chrome.alarms is the replacement for recurring work.
The second big change: the webRequest API lost its blocking capability. In MV2, you could intercept and modify or block network requests synchronously. In MV3, you use declarativeNetRequest instead: a declarative rules-based approach where you describe what to match and what action to take, and Chrome applies the rules without your JavaScript seeing the request at all.
This is better for privacy and performance, and it’s worse for anything that needed dynamic request inspection.
Setting Up a Basic MV3 Extension
The manifest.json is where everything starts. A minimal MV3 manifest:
{
"manifest_version": 3,
"name": "My Extension",
"version": "1.0.0",
"description": "Does something useful",
"permissions": ["storage", "tabs", "activeTab"],
"background": {
"service_worker": "background.js",
"type": "module"
},
"content_scripts": [
{
"matches": ["https://*.example.com/*"],
"js": ["content.js"]
}
],
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}
}
The "type": "module" in the background declaration lets you use ES module import syntax in your service worker. Use it. It makes the code much cleaner.
Working With the Ephemeral Service Worker
The single biggest source of bugs in MV3 extensions is treating the service worker like a persistent background page. Here’s the correct pattern for anything that needs to survive across service worker lifecycles:
// background.js (service worker)
// BAD: state stored in memory
let userSettings = {}; // gone when service worker sleeps
// GOOD: read from storage when needed
async function getSettings() {
return new Promise(resolve => {
chrome.storage.local.get(['settings'], result => {
resolve(result.settings ?? {});
});
});
}
// Listening for messages from content scripts or popup
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'GET_DATA') {
// Must return true to keep the message channel open for async response
handleGetData(message.payload).then(sendResponse);
return true;
}
});
async function handleGetData(payload) {
const settings = await getSettings();
// do work
return { result: 'data' };
}
The return true in onMessage is critical. Without it, the message channel closes before your async operation finishes, and sendResponse is called too late. Chrome drops it silently.
For recurring tasks, use chrome.alarms:
// Schedule a recurring alarm (minimum period is 1 minute in Chrome)
chrome.alarms.create('sync-data', { periodInMinutes: 5 });
chrome.alarms.onAlarm.addListener(alarm => {
if (alarm.name === 'sync-data') {
syncData();
}
});
The 1-minute minimum is a real constraint. If you need sub-minute intervals, you’re better off doing the work in a content script that runs in the context of an open tab.
Content Scripts: The Right Tool for DOM Work
Content scripts run in the context of a web page but in an isolated JavaScript environment. They can read and modify the DOM. They cannot access the page’s JavaScript variables or functions (isolation is enforced). They communicate with the service worker via chrome.runtime.sendMessage.
// content.js — injected into matching pages
// Reading from the DOM
const priceElement = document.querySelector('[data-price]');
if (priceElement) {
const price = priceElement.textContent.trim();
// Send to service worker
chrome.runtime.sendMessage({ type: 'PRICE_FOUND', price }, response => {
if (chrome.runtime.lastError) {
// Service worker may be starting up — this is normal
console.log('Service worker not ready:', chrome.runtime.lastError.message);
}
});
}
// Listening for commands from the popup or service worker
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'HIGHLIGHT') {
document.querySelectorAll(message.selector).forEach(el => {
el.style.outline = '2px solid red';
});
sendResponse({ done: true });
}
});
One thing content scripts can do that service workers cannot: maintain a long-lived connection to a remote WebSocket. If your extension needs a persistent connection to your backend, run it from a content script injected into a tab the user keeps open (like your app’s dashboard page).
The Popup: A Regular Web Page
The popup is just an HTML file. It opens when the user clicks the extension icon and closes when they click away. Because it’s a normal web context, you can use any web APIs. The popup communicates with content scripts via chrome.tabs.sendMessage and with the service worker via chrome.runtime.sendMessage.
// popup.js
document.addEventListener('DOMContentLoaded', async () => {
// Get the active tab
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
// Send a message to the content script running in that tab
try {
const response = await chrome.tabs.sendMessage(tab.id, { type: 'GET_PAGE_DATA' });
document.getElementById('output').textContent = JSON.stringify(response);
} catch (err) {
// Content script not injected on this page
document.getElementById('output').textContent = 'No data available on this page.';
}
});
The try/catch around sendMessage is important. If the content script isn’t running on the current tab (because the page URL doesn’t match your content_scripts patterns), the message throws.
Network Request Interception With declarativeNetRequest
If you need to block or redirect requests, declarativeNetRequest is the MV3 approach. You define rules in a JSON file, reference it from the manifest, and Chrome applies them without your service worker seeing the requests.
// manifest.json (relevant section)
{
"permissions": ["declarativeNetRequest"],
"declarative_net_request": {
"rule_resources": [
{
"id": "ruleset_1",
"enabled": true,
"path": "rules.json"
}
]
}
}
// rules.json
[
{
"id": 1,
"priority": 1,
"action": { "type": "block" },
"condition": {
"urlFilter": "||ads.example.com^",
"resourceTypes": ["script", "image", "xmlhttprequest"]
}
},
{
"id": 2,
"priority": 1,
"action": {
"type": "redirect",
"redirect": { "url": "https://your-proxy.com/api" }
},
"condition": {
"urlFilter": "||api.target.com/v1/endpoint",
"resourceTypes": ["xmlhttprequest"]
}
}
]
For dynamic rules (ones you add or remove based on user settings), use chrome.declarativeNetRequest.updateDynamicRules:
// Add a dynamic rule from the service worker
await chrome.declarativeNetRequest.updateDynamicRules({
addRules: [{
id: 100,
priority: 1,
action: { type: 'block' },
condition: {
urlFilter: `||${domain}^`,
resourceTypes: ['script', 'image', 'xmlhttprequest']
}
}],
removeRuleIds: [] // IDs of rules to remove
});
Dynamic rules are capped at 5,000 per extension. Static rules in JSON files can go up to 30,000. If you’re building something like an ad blocker with tens of thousands of rules, static rules in multiple rule files are the approach.
Storage: Your Options
Extensions have three storage areas:
| Storage | Quota | Synced across devices | When to use |
|---|---|---|---|
chrome.storage.local | 10MB | No | Large local data, sensitive data |
chrome.storage.sync | 100KB | Yes (via Chrome account) | User preferences |
chrome.storage.session | 10MB | No (cleared on restart) | Transient state within a browser session |
chrome.storage.sync is genuinely useful for small user settings (theme preferences, feature toggles) because they follow the user across machines without you building sync infrastructure. The 100KB limit matters: it’s not for large data sets.
chrome.storage.session was added in Chrome 102 and is perfect for state that should survive service worker restarts but not browser restarts. Cache authentication tokens here if your extension authenticates with a backend.
Permissions: Request Only What You Need
Chrome Web Store reviewers check permissions carefully. Requesting broad permissions without justification delays or blocks publication.
Common permissions and what they allow:
activeTab: access to the currently active tab when the user invokes the extension (clicks the icon, uses a keyboard shortcut). More limited thantabsbut often sufficient.tabs: access to all open tabs’ URLs and titles. Required for features that enumerate or monitor tabs.storage: access tochrome.storageAPIs. Always declare this if you store anything.scripting: programmatic injection of content scripts viachrome.scripting.executeScript. Needed if you inject scripts dynamically rather than declaring them in the manifest.host_permissions: access to specific domains. Prefer specific patterns (https://api.yourdomain.com/*) over broad ones (<all_urls>).
If you ask for <all_urls> host permission, Chrome shows a prominent warning to users during installation. Narrow host permissions increase install conversion.
Publishing and the Review Process
Publishing to the Chrome Web Store requires a $5 one-time developer registration fee. Extensions go through a manual review process that takes anywhere from a few days to a few weeks for the first submission. Updates to existing extensions review faster.
What causes review delays or rejection:
- Requesting overly broad permissions without a clear use case described in the store listing
- Remotely fetching and executing code (the “no remote code execution” policy is strict in MV3)
- Unclear privacy policy for extensions that handle user data
- Functionality that violates Google’s developer program policies (no deceptive behavior, no keyword stuffing in the name)
The “no remote code execution” rule is significant. Your extension cannot fetch JavaScript and execute it with eval or by creating script tags. All code must be bundled in the extension package at submission time. This is why many MV3 extensions use bundlers like Vite or Webpack. You build your React or TypeScript code into static files and include them in the package.
Practical Structure for a Real Extension
For anything beyond a trivial one-file extension, a bundled setup pays off:
my-extension/
src/
background/
index.ts # Service worker entry
content/
index.ts # Content script entry
popup/
index.html
App.tsx # React popup UI
main.tsx
public/
manifest.json
icons/
dist/ # Built output (what gets published)
vite.config.ts
A Vite config for multi-entry extension builds:
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
input: {
popup: resolve(__dirname, 'src/popup/index.html'),
background: resolve(__dirname, 'src/background/index.ts'),
content: resolve(__dirname, 'src/content/index.ts'),
},
output: {
entryFileNames: '[name].js',
chunkFileNames: '[name].js',
}
},
outDir: 'dist',
}
});
The entryFileNames: '[name].js' keeps predictable output file names so your manifest.json references work consistently after each build.
The Thing That Still Doesn’t Work Well
One genuine limitation that hasn’t been resolved: extensions cannot maintain persistent connections to remote services from the service worker. If you’re building an extension that needs to receive real-time push events from your backend (notifications, data updates, collaborative state), you have limited options:
- Poll on a schedule with
chrome.alarms(works, but minimum 1-minute interval) - Maintain the connection from a content script running in the user’s active tab (fragile; breaks when they navigate away)
- Use Web Push via a service worker in a PWA companion to the extension (complex but possible)
For most use cases polling is fine. For real-time collaborative tools, extensions are still a somewhat awkward fit compared to native apps or PWAs.
The extension ecosystem is healthy despite the friction of MV3. Users install tools that solve real problems. The combination of service workers, declarative rules, and well-structured storage gives you enough to build genuinely useful software. The architecture just requires you to stop thinking about the background as a long-running server and start thinking of it as a series of event handlers that get woken up when needed.
Sponsored
More from this category
More from Web Development
NestJS in 2026: The Enterprise Node.js Framework Most Teams Overlook
Test-Driven Development With AI Coding Assistants: Does TDD Still Make Sense in 2026?
WebGPU in 2026: What You Can Actually Build With GPU Compute in the Browser
Sponsored
The dispatch
Working notes from
the studio.
A short letter twice a month — what we shipped, what broke, and the AI tools earning their keep.
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored