Chrome Extension Promise Based Apis — Manifest V3 Guide

18 min read

Promise-Based APIs in MV3

A comprehensive guide to migrating from callback-based Chrome extension APIs to promise-based patterns in Manifest V3.

Overview

In Manifest V3, most chrome.* APIs now return Promises when no callback is provided. This represents a significant shift from the callback-based pattern used in Manifest V2, bringing Chrome extension APIs in line with modern JavaScript async/await patterns.

Key change: When you omit the callback parameter, Chrome APIs return a Promise instead of requiring you to pass a callback function. You cannot use both on the same function call – if you pass a callback, the function will not return a promise.

// MV2 (callback-based)
chrome.storage.local.get("count", (result) => {
  console.log(result.count);
});

// MV3 (promise-based)
const result = await chrome.storage.local.get("count");
console.log(result.count);

The Change: Before/After Code

Storage API

Before (MV2 with callbacks):

chrome.storage.local.get("settings", (result) => {
  if (chrome.runtime.lastError) {
    console.error(chrome.runtime.lastError);
    return;
  }
  console.log(result.settings);
});

After (MV3 with promises):

try {
  const result = await chrome.storage.local.get("settings");
  console.log(result.settings);
} catch (error) {
  console.error(error);
}

Tabs API

Before (MV2 with callbacks):

chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
  if (chrome.runtime.lastError) {
    console.error(chrome.runtime.lastError);
    return;
  }
  const tab = tabs[0];
  chrome.tabs.update(tab.id, { url: "https://example.com" }, () => {
    // Tab updated
  });
});

After (MV3 with promises):

const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
await chrome.tabs.update(tab.id, { url: "https://example.com" });

Downloads API

Before (MV2 with callbacks):

chrome.downloads.download({
  url: "https://example.com/file.pdf",
  filename: "file.pdf"
}, (downloadId) => {
  if (chrome.runtime.lastError) {
    console.error(chrome.runtime.lastError);
    return;
  }
  console.log("Download started:", downloadId);
});

After (MV3 with promises):

const downloadId = await chrome.downloads.download({
  url: "https://example.com/file.pdf",
  filename: "file.pdf"
});
console.log("Download started:", downloadId);

APIs That Return Promises

The following Chrome APIs return Promises in MV3 when the callback is omitted:

API Method Return Type
tabs create(), update(), remove(), reload(), goBack(), goForward(), captureVisibleTab(), query(), get(), highlight() Promise<Tab> / Promise<number> / Promise<Tab[]>
storage get(), set(), remove(), clear() Promise<{[key: string]: any}>
permissions contains(), request(), remove() Promise<boolean>
scripting executeScript(), insertCSS(), removeCSS(), getRegisteredContentScripts() Promise<InjectionResult[]> / Promise<ContentScriptInfo[]>
action setBadgeText(), setBadgeBackgroundColor(), setTitle(), setIcon(), setPopup(), openPopup() Promise<void>
alarms create(), get(), getAll(), clear(), clearAll() Promise<Alarm> / Promise<Alarm[]>
cookies get(), getAll(), set(), remove(), getCookieStore() Promise<Cookie> / Promise<Cookie[]>
downloads download(), search(), pause(), resume(), cancel(), erase(), open(), show(), showDefaultFolder() Promise<number> / Promise<void>
notifications create(), update(), clear() Promise<string> / Promise<boolean>
contextMenus update(), remove(), removeAll() (Chrome 123+) Promise<void>
runtime sendMessage(), sendNativeMessage() Promise<any>

APIs Still Using Callbacks

Not all Chrome APIs have been converted to return Promises. These APIs still require callback patterns:

API Reason
runtime.onMessage Event listeners cannot return promises; they must handle messages synchronously
webRequest High-performance event API requiring synchronous blocking
storage.onChanged Event listener for storage changes
tabs.onUpdated Event listener for tab updates
windows.onFocusChanged Event listener for window focus changes

Handling Event Listeners (Still Callback-Based)

Event listeners continue to use callbacks because they respond to events asynchronously:

// This still uses callbacks - cannot be promise-based
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type === "GET_DATA") {
    const data = getData();
    sendResponse({ data });
  }
  return true; // Keep message channel open for async response
});

How @theluckystrike/webext-storage Handles This

The @theluckystrike/webext-storage library provides a promise-based interface that works seamlessly with MV3:

import { defineSchema, createStorage } from "@theluckystrike/webext-storage";

// Define your schema
const schema = defineSchema({
  count: { type: "number", default: 0 },
  settings: {
    type: "object",
    properties: {
      theme: { type: "string", default: "light" },
      notifications: { type: "boolean", default: true }
    },
    default: { theme: "light", notifications: true }
  }
});

// Create storage instance
const storage = createStorage({ schema });

// Always async/await - works in MV3
const count = await storage.get("count");
console.log(count); // 0 (or stored value)

// Set values
await storage.set("count", count + 1);

// Remove values
await storage.remove("count");

Benefits:

How @theluckystrike/webext-messaging Handles This

The @theluckystrike/webext-messaging library wraps callback-based message passing into clean promises:

import { createMessenger, MessagingError } from "@theluckystrike/webext-messaging";

// Define your message types
interface Messages = {
  getData: { key: string };
  setData: { key: string; value: any };
  getDataResponse: { value: any };
};

// Create messenger instance
const msg = createMessenger<Messages>();

// Send message and wait for response - automatically returns a promise
const data = await msg.send("getData", { key: "myKey" });
console.log(data.value);

// Error handling with MessagingError
try {
  const result = await msg.send("getData", { key: "myKey" });
} catch (error) {
  if (error instanceof MessagingError) {
    console.error("Messaging error:", error.message);
  }
}

Benefits:

How @theluckystrike/webext-permissions Handles This

The @theluckystrike/webext-permissions library wraps chrome.permissions callbacks into promises:

import { checkPermission, requestPermission, removePermission } from "@theluckystrike/webext-permissions";

// Check if a permission is granted
const hasTabs = await checkPermission("tabs");
console.log("Has tabs permission:", hasTabs);

// Check multiple permissions
const hasAll = await checkPermission(["tabs", "storage", "activeTab"]);

// Request a permission
const granted = await requestPermission("tabs");
if (granted) {
  console.log("Tabs permission granted!");
}

// Remove a permission
await removePermission("tabs");

Benefits:

Migration Patterns

Pattern 1: Callback to Await

Transform callback-based code to async/await:

// ❌ MV2 style
function getSettings(callback) {
  chrome.storage.local.get("settings", (result) => {
    callback(result.settings);
  });
}

// ✅ MV3 style
async function getSettings() {
  const result = await chrome.storage.local.get("settings");
  return result.settings;
}

Pattern 2: Error Handling (lastError to try/catch)

When using promise-based calls, errors become rejected promises instead of requiring chrome.runtime.lastError checks (though lastError still exists for callback-based usage):

// ❌ MV2 style
chrome.storage.local.get("key", (result) => {
  if (chrome.runtime.lastError) {
    console.error("Error:", chrome.runtime.lastError.message);
    return;
  }
  // Success
});

// ✅ MV3 style
try {
  const result = await chrome.storage.local.get("key");
  // Success
} catch (error) {
  console.error("Error:", error.message);
}

Pattern 3: Parallel Requests with Promise.all

Execute multiple async operations in parallel:

// ❌ MV2 style (nested callbacks)
chrome.storage.local.get("key1", (result1) => {
  chrome.storage.local.get("key2", (result2) => {
    chrome.storage.local.get("key3", (result3) => {
      // All done
    });
  });
});

// ✅ MV3 style (parallel execution)
const [result1, result2, result3] = await Promise.all([
  chrome.storage.local.get("key1"),
  chrome.storage.local.get("key2"),
  chrome.storage.local.get("key3")
]);

Pattern 4: Using @theluckystrike Packages

Leverage the libraries for consistent promise-based APIs:

import { createStorage } from "@theluckystrike/webext-storage";
import { createMessenger } from "@theluckystrike/webext-messaging";
import { checkPermission, requestPermission } from "@theluckystrike/webext-permissions";

const storage = createStorage({ schema: {} });
const messenger = createMessenger();

// Storage - always promise-based
await storage.set("user", { name: "John" });

// Messaging - promise-based
await messenger.send("updateUser", { name: "John" });

// Permissions - promise-based
const granted = await requestPermission("storage");

chrome.runtime.lastError

In MV2, chrome.runtime.lastError was checked after every async API call. In MV3, when using promise-based calls (omitting the callback), errors are thrown as rejected Promises. Note: chrome.runtime.lastError still works when callbacks are used, but chrome.extension.lastError is deprecated.

// ❌ MV2 - checking lastError
chrome.tabs.create({ url: "https://example.com" }, (tab) => {
  if (chrome.runtime.lastError) {
    console.error(chrome.runtime.lastError.message);
    return;
  }
  console.log("Tab created:", tab.id);
});

// ✅ MV3 - try/catch
try {
  const tab = await chrome.tabs.create({ url: "https://example.com" });
  console.log("Tab created:", tab.id);
} catch (error) {
  console.error("Error creating tab:", error.message);
}

Common errors:

TypeScript Setup

For full TypeScript support with Chrome APIs, use the chrome-types package:

npm install -D chrome-types

Then add to your tsconfig.json:

{
  "compilerOptions": {
    "types": ["chrome-types"]
  }
}

This provides full type definitions for all Chrome APIs including promise return types:

// Full autocomplete and type checking
const tab = await chrome.tabs.create({ url: "https://example.com" });
tab.id;       // number | undefined
tab.url;      // string | undefined
tab.title;    // string | undefined

TypeScript with @theluckystrike Packages

import { createStorage, defineSchema } from "@theluckystrike/webext-storage";

// Define schema with full type safety
const schema = defineSchema({
  user: {
    type: "object",
    properties: {
      id: { type: "number" },
      name: { type: "string" },
      email: { type: "string" }
    },
    required: ["id", "name"],
    default: { id: 0, name: "", email: "" }
  }
});

const storage = createStorage({ schema });

// Type-safe get
const user = await storage.get("user");
// user is typed as { id: number; name: string; email: string }

Gotchas

1. Not All APIs Are Promisified {#1-not-all-apis-are-promisified}

Some APIs still require callbacks:

// Still uses callback
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  // ...
});

2. contextMenus.create Does NOT Return a Promise {#2-contextmenuscreate-does-not-return-a-promise}

// ⚠️ create() returns number|string synchronously, NOT a promise
const menuId = chrome.contextMenus.create({
  title: "My Menu",
  contexts: ["selection"]
});
// Use the optional callback parameter for error handling
// Note: update(), remove(), and removeAll() DO return promises (Chrome 123+)

3. Always Use try/catch {#3-always-use-trycatch}

Unhandled promise rejections in service workers can crash your extension:

// ❌ Dangerous - unhandled rejection can crash SW
const result = await chrome.storage.local.get("key");

// ✅ Safe - always wrap in try/catch
try {
  const result = await chrome.storage.local.get("key");
} catch (error) {
  console.error("Storage error:", error);
}

4. Unhandled Rejections Crash Service Workers {#4-unhandled-rejections-crash-service-workers}

In MV3 background service workers, unhandled promise rejections terminate the worker:

// ❌ This can crash the service worker
chrome.storage.local.get("nonexistent");

// ✅ Always handle
async function safeGet(key) {
  try {
    return await chrome.storage.local.get(key);
  } catch {
    return null;
  }
}

5. Event Listeners Cannot Be Async {#5-event-listeners-cannot-be-async}

// ❌ Invalid - event listeners can't return promises
chrome.runtime.onMessage.addListener(async (message) => {
  const data = await fetchData(); // This won't work properly
  return data;
});

// ✅ Correct - handle async internally
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  fetchData().then(data => sendResponse(data));
  return true; // Keep channel open for async response
});

Migration Checklist

Part of the Chrome Extension Guide by theluckystrike. Built at zovo.one.