In our earlier guides we covered manifest.json and content scripts. This post covers the piece that trips up most developers moving to Manifest V3: the background service worker, and how the parts of an extension talk to each other.
What changed from background pages
In Manifest V2 the background page stayed alive as long as the browser was open. In V3 it is replaced by a service worker that Chrome starts when an event arrives and stops again after about 30 seconds of inactivity. That single change explains almost every “my extension randomly stops working” bug.
- There is no
windowordocument, so no DOM APIs. - Global variables are lost whenever the worker shuts down.
setTimeoutandsetIntervalare unreliable for anything longer than a few seconds.
Registering the service worker
{
"manifest_version": 3,
"name": "My Extension",
"version": "1.0.0",
"background": {
"service_worker": "background.js",
"type": "module"
},
"permissions": ["storage", "alarms"]
}
"type": "module" lets you use import statements inside the worker.
Rule 1: register listeners at the top level
When Chrome wakes the worker for an event, it runs your script from the top and then dispatches the event. If the listener is added inside a callback or after an await, it is not registered yet and the event is missed.
// Good: registered synchronously on startup
chrome.runtime.onInstalled.addListener(() => {
chrome.storage.local.set({ count: 0 });
});
// Bad: may miss events after the worker restarts
chrome.storage.local.get('enabled').then(({ enabled }) => {
if (enabled) chrome.tabs.onUpdated.addListener(onTabUpdated);
});
Rule 2: keep state in chrome.storage
Anything that must survive a restart belongs in chrome.storage. Use storage.session for data that should only last for the browser session, and storage.local for data that should persist.
async function increment() {
const { count = 0 } = await chrome.storage.local.get('count');
await chrome.storage.local.set({ count: count + 1 });
return count + 1;
}
Rule 3: use chrome.alarms for timers
chrome.runtime.onInstalled.addListener(() => {
chrome.alarms.create('sync', { periodInMinutes: 15 });
});
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === 'sync') syncData();
});
Alarms wake the worker when they fire, so they keep working even after Chrome has shut it down.
One-off messages
Popups, options pages and content scripts send messages to the worker with chrome.runtime.sendMessage. The worker answers through sendResponse.
// popup.js
const { count } = await chrome.runtime.sendMessage({ type: 'increment' });
document.querySelector('#count').textContent = count;
// background.js
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'increment') {
increment().then((count) => sendResponse({ count }));
return true; // keep the channel open for the async response
}
});
Forgetting return true is the most common messaging bug: the channel closes before the promise resolves and the sender receives undefined.
Messaging a content script
To reach a content script, send to a specific tab with chrome.tabs.sendMessage:
// background.js or popup.js
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
const { title } = await chrome.tabs.sendMessage(tab.id, { type: 'getTitle' });
// content.js
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'getTitle') sendResponse({ title: document.title });
});
If the tab has no content script (for example a chrome:// page, or a tab opened before the extension was installed), the call rejects with “Receiving end does not exist”. Wrap it in try/catch.
Long-lived connections with ports
For a stream of messages, such as progress updates, open a port instead of sending many one-off messages:
// content.js
const port = chrome.runtime.connect({ name: 'progress' });
port.onMessage.addListener((msg) => console.log('progress', msg.percent));
// background.js
chrome.runtime.onConnect.addListener((port) => {
if (port.name !== 'progress') return;
let percent = 0;
const timer = setInterval(() => {
percent += 10;
port.postMessage({ percent });
if (percent >= 100) clearInterval(timer);
}, 500);
port.onDisconnect.addListener(() => clearInterval(timer));
});
Need the DOM? Use an offscreen document
Tasks such as parsing HTML, playing audio or reading the clipboard need a DOM. The chrome.offscreen API (with the offscreen permission) creates a hidden page that the worker can message like any other part of the extension.
Debugging tips
- Open
chrome://extensionsand click service worker on your extension’s card to get its DevTools console. - Keeping DevTools open stops the worker from shutting down, so close it when you test restart behaviour.
chrome://serviceworker-internalsshows when the worker starts and stops.
Wrapping up
Treat the service worker as something that can disappear at any time: register listeners at the top level, store state in chrome.storage, schedule work with chrome.alarms, and return true from async message handlers. Follow those four rules and most Manifest V3 headaches go away.




Comments