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.

Published October 2, 2026 Updated October 2, 2026 7 min read
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.

Async styles by engine and API shapeHow Chrome, Firefox and Safari handle promise-returning methods, callback methods, lastError, and promises returned from runtime.onMessage listeners in MV3.AspectChrome MV3FirefoxSafariMethods return promisesYes (no callback)YesYesCallback formSupportedchrome.* aliaschrome.* aliasErrorsReject / lastErrorRejectRejectonMessage returns promiseIgnored (use sendResponse)Used as replyUsed as replyreturn true + sendResponseYesYesYes
Methods are promise-friendly everywhere; onMessage's return value is where engines diverge.

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.

Why a promise returned from onMessage is lost in ChromeThe content script sends a message; Chrome's listener returns a promise, which Chrome treats as a non-true return and closes the channel; the sender receives undefined; with return true and sendResponse the reply arrives.Content scriptChrome runtimeService workersendMessage({type:'get'})onMessage(msg, sender, sendResponse)returns Promise → not trueresolves undefinedreturn true; later sendResponse(data)resolves data
In Chrome, the channel stays open only if the listener returns literally true.

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.

Converging a mixed codebaseWrap message listeners with a return-true adapter, convert method calls to await module by module, lint against callback-plus-await, then remove lastError checks as callbacks disappear.onMessage adapterreturn true + sendResponseConvert callsawait, module by moduleLint ruleno callback + awaitas callbacks disappearRemove lastErrortry/catch insteadOne namespacechrome.* or browser.*Drop polyfillif only for promises
Fix the message boundary first; method calls can follow gradually.

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 onMessage in Chrome. The sender gets undefined.
  • Callback and await on the same call. The result is undefined.
  • Reading lastError outside 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; onMessage requires return true + sendResponse for async replies.
  • Firefox: browser.* promises everywhere; onMessage accepts a returned promise as the reply; return true + sendResponse also works.
  • Safari: like Firefox for promises and onMessage; some older Safari versions have quirks with sendResponse after long delays — keep handlers fast.

Verification

  1. Grep for lastError and confirm each remaining use is inside a callback.
  2. Run a lint rule that flags await on calls with callback arguments.
  3. Send a message whose handler throws, in each engine, and confirm the sender receives { ok: false, error }.
  4. 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.

Other Core APIs & Cross-Browser Data Management Resources