Handling Promise and Callback API Differences
Mix chrome.* callbacks and promises safely in MV3: which APIs return promises, lastError semantics, sendResponse versus returned promises in onMessage, events that still need callbacks, and a small adapter.
Table of Contents
A codebase that started in MV2 is full of callbacks: chrome.storage.local.get(keys, (items) => …) with chrome.runtime.lastError checks scattered after them. New code uses await chrome.storage.local.get(keys). Both work in Chrome MV3 — until they do not: an onMessage listener returns a promise and Chrome ignores it, a callback-style call swallows an error that the promise version would have thrown, and a helper written for Firefox’s browser.* breaks in older Chrome. The rules for which style works where are simple once laid out. This guide lays them out and shows how to converge on one style. It belongs to cross-browser API compatibility.
The rules in brief
In Manifest V3, Chrome’s asynchronous chrome.* methods return a promise when called without a callback, and still accept a callback for backward compatibility; you must not pass both. Errors surface differently in each style: a promise rejects, while a callback receives undefined (or nothing) and the error appears in chrome.runtime.lastError, which Chrome logs as “Unchecked runtime.lastError” if you never read it. Firefox and Safari expose browser.* with promises everywhere and also provide chrome.* aliases that, in MV3, also return promises. Event listeners are a separate case: they are always callbacks, and the one event where the return value matters — runtime.onMessage — differs between engines in how it treats a returned promise.
Step-by-step: converge on promises safely
1. Prefer promises for every method call
1// Before (MV2 style)
2chrome.storage.local.get("prefs", (items) => {
3 if (chrome.runtime.lastError) return console.error(chrome.runtime.lastError.message);
4 apply(items.prefs);
5});
6
7// After (MV3)
8try {
9 const { prefs } = await chrome.storage.local.get("prefs");
10 apply(prefs);
11} catch (err) {
12 console.error(err.message);
13}
Execution context: any extension context. With promises, errors propagate through try/catch and async functions naturally, and nothing is left unchecked. Convert one module at a time; the two styles can coexist safely as long as no single call passes a callback and awaits the result.
2. Never pass a callback and await the same call
1// WRONG — the callback suppresses the promise; `result` is undefined
2const result = await chrome.tabs.query({ active: true }, (tabs) => console.log(tabs));
3
4// RIGHT — choose one
5const tabs = await chrome.tabs.query({ active: true });
Execution context: any extension context. When a callback is supplied, Chrome’s method returns undefined, so awaiting it yields undefined immediately — a subtle bug that shows up as “tabs is undefined” on the next line. Lint for it: an await expression whose call includes a function argument in the callback position is almost always this mistake.
3. Write onMessage listeners that work in every engine
1// sw.js
2chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
3 const handler = HANDLERS[msg?.type];
4 if (!handler) return; // not ours: let other listeners answer
5 Promise.resolve()
6 .then(() => handler(msg, sender))
7 .then((data) => sendResponse({ ok: true, data }),
8 (err) => sendResponse({ ok: false, error: String(err?.message ?? err) }));
9 return true; // keep the channel open (Chrome needs this)
10});
Execution context: the service worker. Returning true and calling sendResponse works in Chrome, Firefox and Safari alike. Returning a promise works in Firefox and Safari but not in Chrome, where the sender receives undefined. Wrapping the result as { ok, data | error } gives the sender a uniform way to detect failures, since errors thrown in the listener never propagate across the message boundary on their own. More in wrapping message passing in promises.
4. Read lastError where callbacks remain
1// APIs or code paths still using callbacks
2chrome.tabs.sendMessage(tabId, msg, (reply) => {
3 if (chrome.runtime.lastError) {
4 // e.g. "Could not establish connection. Receiving end does not exist."
5 return handleNoContentScript(tabId);
6 }
7 handleReply(reply);
8});
Execution context: any extension context. lastError is set only during the callback; reading it later, or in another callback, gives undefined. If you never read it, Chrome logs an “Unchecked runtime.lastError” warning, which is noisy but harmless — and a sign you are ignoring a real failure. The promise form of tabs.sendMessage rejects with the same message, which is easier to handle.
5. Choose one namespace
1// platform.js
2export const ext = globalThis.browser ?? globalThis.chrome;
Execution context: a shared module. In MV3, chrome.* returns promises in all three engines, so either namespace works. Picking one avoids reviewers and teammates wondering whether two code paths behave differently. If you use TypeScript, @types/chrome types chrome.* with promise overloads; webextension-polyfill types browser.*. See typing chrome and browser APIs in TypeScript.
6. Wrap the few APIs that still lack promises
1// Some event-like or legacy methods are callback-only in some versions; wrap them once
2export function promisify(fn, thisArg) {
3 return (...args) => new Promise((resolve, reject) => {
4 fn.call(thisArg, ...args, (result) => {
5 const err = chrome.runtime.lastError;
6 err ? reject(new Error(err.message)) : resolve(result);
7 });
8 });
9}
10
11const getProfileUserInfo = promisify(chrome.identity.getProfileUserInfo, chrome.identity);
Execution context: any extension context. Most MV3 methods return promises natively; for the occasional exception in an older browser version, a one-line wrapper keeps the rest of the code uniform. Read lastError inside the callback, before anything else runs.
7. Test both success and failure paths
1test("reports missing content script", async () => {
2 chrome.tabs.sendMessage.mockRejectedValue(new Error("Receiving end does not exist."));
3 await expect(askTab(1)).resolves.toEqual({ ok: false, reason: "no-content-script" });
4});
Execution context: a unit test with mocked chrome.*. Mocks should model the promise form; if part of your code still uses callbacks, your mock must also set lastError during the callback. Testing failure paths is what catches swallowed errors.
Common mistakes
- Returning a promise from
onMessagein Chrome. The sender getsundefined. - Callback and
awaiton the same call. The result isundefined. - Reading
lastErroroutside its callback. It is cleared by then. - Ignoring rejections. An un-awaited promise that rejects becomes an unhandled rejection in the worker.
- Keeping the polyfill only for promises. In MV3 it is unnecessary weight.
Cross-browser variation
- Chrome / Edge: MV3 methods return promises; callbacks still work;
onMessagerequiresreturn true+sendResponsefor async replies. - Firefox:
browser.*promises everywhere;onMessageaccepts a returned promise as the reply;return true+sendResponsealso works. - Safari: like Firefox for promises and
onMessage; some older Safari versions have quirks withsendResponseafter long delays — keep handlers fast.
Verification
- Grep for
lastErrorand confirm each remaining use is inside a callback. - Run a lint rule that flags
awaiton calls with callback arguments. - Send a message whose handler throws, in each engine, and confirm the sender receives
{ ok: false, error }. - Check the worker console in Chrome for “Unchecked runtime.lastError” warnings during a full test run.
FAQ
Did MV3 remove callbacks?
No. Chrome still accepts them for compatibility. New code should use promises.
Why does Chrome ignore a returned promise in onMessage?
Historically the API’s contract was “return true to respond asynchronously”. Chrome has discussed accepting promises; until it does, use return true.
Do event listeners return promises?
No. Listeners are callbacks you register. Only onMessage (and onMessageExternal) uses the return value.
Are event listener callbacks affected by async functions?
Registering an async function as a listener is fine for every event except onMessage, where an async function always returns a promise — which Chrome treats as “not true” and closes the channel. Use a synchronous listener that starts the async work and returns true, as in step 3.
Related
- Using the WebExtension polyfill in MV3 — when the polyfill still helps.
- Fixing message port closed before response errors — the symptom of a missing
return true. - Handling errors across message boundaries — the
{ ok, error }envelope in depth. - Cross-browser API compatibility — the parent topic.