LocalePack
ChromeFirefoxEdgeOperaSafariDanh sách CWS
Vue.jsReact
Next.jsi18nextReact Native
Hướng dẫn cho lập trình viênCác câu chuyện thành công
Home/Guides/chrome.i18n in MV3 service workers
August 8, 2026

chrome.i18n in Manifest V3 service workers

A claim you will meet repeatedly is that chrome.i18n.getMessage() is unsuitable for Manifest V3 because it is synchronous. That is backwards. Synchronous is exactly what you want in a service worker. The things that genuinely break when a background page becomes a service worker are the DOM, cached globals, localStorage and XMLHttpRequest — and every common i18n bug in MV3 traces back to one of those four.

Synchronous is a feature here, not a bug

chrome.i18n.getMessage() is synchronous and returns a string. So is chrome.i18n.getUILanguage(). Neither takes a callback, neither returns a promise, and neither needs one: the message catalogues are read when the extension loads, so by the time any of your code runs the strings are already in memory.

In a service worker that property is worth a great deal. The Chrome migration guide describes the execution model plainly:

“Service workers are ephemeral, which means they’ll likely start, run, and terminate repeatedly during a user’s browser session. It also means that data is not immediately available in global variables since the previous context was torn down.”

Every asynchronous step in a service worker is an opportunity for the worker to be torn down mid-flight, and every piece of state you hold across an await is state you may lose. A synchronous call has neither problem. There is no promise to await, no initialization phase to sequence, and nothing to restore after a restart:

// background.js — a Manifest V3 service worker

chrome.action.onClicked.addListener(function (tab) {
  // No await. No initialization. Correct on the very first line of a
  // freshly restarted worker, and identical on the thousandth.
  const title = chrome.i18n.getMessage("notificationTitle");
  const body = chrome.i18n.getMessage("notificationBody", [tab.title]);

  chrome.notifications.create({
    type: "basic",
    iconUrl: "icons/128.png",
    title: title,
    message: body,
  });
});

Compare that with a runtime catalogue fetch, which has to be awaited, has to be cached somewhere durable, and has to handle the case where the worker died between the fetch and its use. The synchronous API sidesteps the entire class of problem.

What actually breaks #1: there is no DOM

This is the real gotcha, and it accounts for most “chrome.i18n does not work in MV3” reports. The migration guide is unambiguous about service workers:

“They can’t access the DOM or the window interface”

Nearly every Chrome extension i18n tutorial includes the same small helper: mark up your HTML with data-i18n attributes, then walk the document and substitute the translations. Under Manifest V2 that helper could live in the background page, because a background page was a document. In Manifest V3 it cannot.

Does not work in a service worker:

// background.js — MV3 service worker
// ReferenceError: document is not defined

function localizeHtml() {
  document.querySelectorAll("[data-i18n]").forEach(function (el) {
    el.textContent = chrome.i18n.getMessage(el.dataset.i18n);
  });
}

localizeHtml();

document is not defined in the service worker’s global scope, so the reference throws. Note what is not the problem: getMessage() would have returned the right string. The failure is in the traversal, not the translation.

Works — the same helper, in the document that owns the markup:

// popup.js (or options.js, or a content script)
// Loaded by popup.html with <script src="popup.js" defer></script>

function localizeHtml(root) {
  root.querySelectorAll("[data-i18n]").forEach(function (el) {
    const value = chrome.i18n.getMessage(el.dataset.i18n);
    if (value === "" || value === undefined) {
      console.warn("[i18n] unresolved key:", el.dataset.i18n);
      return; // leave the source text in place
    }
    el.textContent = value;
  });

  root.querySelectorAll("[data-i18n-attr]").forEach(function (el) {
    // data-i18n-attr="title:tooltipSave,aria-label:ariaSave"
    el.dataset.i18nAttr.split(",").forEach(function (pair) {
      const parts = pair.split(":");
      const value = chrome.i18n.getMessage(parts[1]);
      if (value) el.setAttribute(parts[0], value);
    });
  });
}

localizeHtml(document);

The division of responsibility is the whole lesson: the service worker localizes values it passes to Chrome APIs; documents localize their own markup. Nothing needs to cross the boundary, and in particular you should not be sending translated strings from the worker to the popup over chrome.runtime.sendMessage — the popup can call getMessage() itself, synchronously, with no round trip.

If a key resolves to an empty string the helper above logs it and leaves your source markup alone, which is far easier to spot than a blank element. Why getMessage() returns an empty string covers every cause of that.

What actually breaks #2: global variables do not survive

A pattern carried over from Manifest V2 background pages is to resolve every string once at startup and keep it in a module-level object. In a service worker that is pointless at best and actively misleading at worst, for the reason quoted above: data is not available in global variables after the previous context was torn down.

Anti-pattern:

// background.js — MV3 service worker

// Runs on every cold start of the worker, which may be dozens of times
// per browsing session. The object is discarded each time the worker
// is terminated, so this "cache" never actually caches anything.
const STRINGS = {
  title: chrome.i18n.getMessage("notificationTitle"),
  body: chrome.i18n.getMessage("notificationBody"),
  menu: chrome.i18n.getMessage("contextMenuLabel"),
};

chrome.alarms.onAlarm.addListener(function () {
  // Worked in testing because the worker was still warm.
  // In production STRINGS may have been rebuilt seconds ago — or the
  // listener may be running in a context where top-level work was
  // reordered relative to what you expect.
  showNotification(STRINGS.title, STRINGS.body);
});

Fix:

// background.js — MV3 service worker

chrome.alarms.onAlarm.addListener(function () {
  // Resolve at the point of use. It is synchronous, it reads from a
  // catalogue that is already in memory, and it is correct whether the
  // worker started a millisecond ago or has been alive for an hour.
  showNotification(
    chrome.i18n.getMessage("notificationTitle"),
    chrome.i18n.getMessage("notificationBody")
  );
});

There is no performance argument for the cache. getMessage() is a synchronous lookup against a catalogue Chrome already parsed at extension load; calling it in a handler that fires a few times a minute costs nothing measurable. The cache adds a lifecycle dependency in exchange for nothing.

The general rule for MV3 service workers is: keep top-level code limited to registering event listeners, and do the actual work inside the handlers. Applied to i18n, that means no string resolution at module scope.

What actually breaks #3: no window.localStorage

Extensions frequently offer a “display language” setting that overrides the browser UI language. Under Manifest V2 that preference commonly lived in localStorage. It cannot in Manifest V3:

“The web platform’s Storage interface (accessible from window.localStorage) cannot be used in a service worker”

Anti-pattern:

// background.js — MV3 service worker
const chosen = localStorage.getItem("uiLanguage") || "en";

Fix — chrome.storage, which is available in every extension context:

// Reading the preference in the service worker
chrome.runtime.onMessage.addListener(function (msg, sender, sendResponse) {
  if (msg.type !== "getLanguage") return false;

  chrome.storage.sync.get({ uiLanguage: null }).then(function (items) {
    sendResponse({
      // Fall back to what Chrome itself is using.
      language: items.uiLanguage || chrome.i18n.getUILanguage(),
    });
  });

  return true; // keep the message channel open for the async response
});

// Writing it from the options page
document.querySelector("#language").addEventListener("change", function (e) {
  chrome.storage.sync.set({ uiLanguage: e.target.value });
});
An important limitation to design around: chrome.i18n.getMessage() always resolves against the browser’s locale, not a preference you stored. There is no argument for “give me this key in Spanish”. If you need a genuine in-extension language switcher, you have to ship a second set of catalogues that you load and index yourself, and use chrome.i18n only for the browser-locale path. Most extensions are better served by localizing to the browser locale and skipping the switcher entirely.

What actually breaks #4: no XMLHttpRequest

This one only bites if you were fetching translation catalogues at runtime instead of shipping _locales. The constraint:

“XMLHttpRequest() can’t be called from a service worker”

Libraries that predate Manifest V3 sometimes load JSON catalogues over XMLHttpRequest internally, so the failure can appear inside a dependency rather than in your own code. fetch() is available in a service worker and is the direct replacement:

// Reading a packaged JSON file from the service worker
fetch(chrome.runtime.getURL("_locales/en/messages.json"))
  .then(function (r) { return r.json(); })
  .then(function (catalogue) {
    console.log("keys:", Object.keys(catalogue).length);
  });

That said, the better answer is usually not to fetch at all. Shipping _locales/ and letting Chrome resolve strings for you removes the network dependency, removes the async initialization, removes the caching question, and gives you localized manifest fields and Web Store metadata as a side effect. It is also what makes the synchronous getMessage() possible in the first place. See the messages.json format explained for the catalogue structure.

Which chrome.i18n calls are safe where

All four methods are callable from all four contexts. What differs is how you have to write the call.

MethodShapeService workerPopup / options / content script
getMessage()Synchronous, returns stringCall it directly, at the point of use. No await, no init, no cache.Call it directly. Also the right place for data-i18n DOM traversal.
getUILanguage()Synchronous, returns stringCall it directly. Useful for logging which catalogue is in play.Call it directly.
getAcceptLanguages()Asynchronous, returns PromiseAwait it inside an event handler, not at module scope.Ordinary promise handling.
detectLanguage()Asynchronous, returns PromiseAwait it inside an event handler; return true from onMessage listeners.Ordinary promise handling.

The two synchronous methods need no special handling anywhere. The two asynchronous ones return promises and need ordinary promise handling in every context — there is nothing service-worker-specific about them except the lifecycle: start them from inside the event handler that needs the result rather than at module scope, so the work is tied to an event the worker is alive to serve.

// Correct: async i18n work inside the handler that needs it
chrome.runtime.onMessage.addListener(function (msg, sender, sendResponse) {
  if (msg.type !== "detect") return false;

  chrome.i18n.detectLanguage(msg.text).then(function (result) {
    sendResponse(result);
  });

  return true; // keep the channel open until sendResponse fires
});
Returning true from an onMessage listener is what keeps the message channel open for an asynchronous sendResponse. Forget it and your caller receives undefined — a failure that looks like an i18n bug and is not one.

Localizing what genuinely lives in the worker

Once the DOM traversal moves out, a real list remains of user-visible strings the service worker owns. These are the calls that belong there.

Notifications

chrome.notifications.create("sync-done", {
  type: "basic",
  iconUrl: "icons/128.png",
  title: chrome.i18n.getMessage("syncDoneTitle"),
  message: chrome.i18n.getMessage("syncDoneBody", [String(count)]),
  buttons: [{ title: chrome.i18n.getMessage("viewDetails") }],
});

Notice String(count). Substitutions must be strings; a number silently does the wrong thing in some code paths and is trivial to guard against.

Context menus

function buildMenus() {
  chrome.contextMenus.removeAll(function () {
    chrome.contextMenus.create({
      id: "translate-selection",
      title: chrome.i18n.getMessage("menuTranslateSelection"),
      contexts: ["selection"],
    });
  });
}

// Menu items persist for the extension, so their labels are captured at
// creation time. Rebuild on startup as well as on install so a changed
// browser language is picked up.
chrome.runtime.onInstalled.addListener(buildMenus);
chrome.runtime.onStartup.addListener(buildMenus);

The removeAll() call matters: creating a menu item with an id that already exists is an error, and onInstalled plus onStartup will both fire over the life of an installation.

Alarm-driven messages

chrome.alarms.onAlarm.addListener(function (alarm) {
  if (alarm.name !== "daily-reminder") return;

  // Resolved here, not at module scope — see the caching section above.
  chrome.notifications.create({
    type: "basic",
    iconUrl: "icons/128.png",
    title: chrome.i18n.getMessage("reminderTitle"),
    message: chrome.i18n.getMessage("reminderBody"),
  });
});

Toolbar button state

// Static toolbar strings belong in the manifest, where Chrome performs
// __MSG_key__ substitution for you. Use the API for anything that
// changes at runtime:
chrome.action.setTitle({
  tabId: tab.id,
  title: chrome.i18n.getMessage("actionTitleEnabled"),
});

chrome.action.setBadgeText({ tabId: tab.id, text: String(count) });

Badge text is one of the few strings you should generally not put through the catalogue — it is two or three characters and usually a number. Titles, tooltips, notification bodies and menu labels all should go through it. The manifest side of this is covered in __MSG_key__ in manifest.json.

Migration checklist for an MV2 background page

  • •Search the background script for document, window, and DOMParser. Every hit is code that must move to a popup, options page or content script.
  • •Search for a data-i18n traversal. It belongs in the document that owns the markup, loaded from that page’s own script.
  • •Delete any module-level object that caches resolved strings. Call getMessage() at the point of use instead.
  • •Replace localStorage with chrome.storage, including any stored language preference.
  • •Replace XMLHttpRequest with fetch(), or better, delete the runtime catalogue fetch and ship _locales/.
  • •Move top-level work into event listeners. Register the listeners at module scope; do the work inside them.
  • •Rebuild context menus on onStartup as well as onInstalled, so labels track the browser language.
  • •Validate the catalogue itself before blaming the runtime — see validating messages.json.

Testing this properly

The lifecycle bugs above hide during development because a worker you are actively debugging stays warm. Two habits expose them:

  • •Open the service worker inspector from the extension’s card on chrome://extensions and watch it stop and restart. Trigger your feature immediately after a restart, not while you have been stepping through it.
  • •Run the debugMessage() helper from the empty-string guide inside the worker inspector. It distinguishes an empty string from undefined, which tells you whether you have a catalogue problem or a call-site problem.
  • •Change Chrome’s UI language and restart the browser, then re-test the worker-side surfaces specifically. Notification titles and context menu labels are the ones that go stale.

Frequently asked questions

Is chrome.i18n.getMessage() usable in a Manifest V3 service worker?

Yes, and its synchronous signature is an advantage there rather than a limitation. Because it returns a string immediately, there is no promise to await and no state to lose when the worker is torn down and restarted. The common claim that a synchronous API is unsuitable for MV3 gets this backwards.

Why does document.querySelectorAll('[data-i18n]') fail in my MV3 background script?

Because service workers "can't access the DOM or the window interface", so document is not defined and the reference throws. The DOM traversal helper belongs in the popup, the options page or a content script — the document that owns the markup — where it can call chrome.i18n.getMessage() itself with no round trip to the worker.

Should I cache resolved i18n strings in a global variable in the service worker?

No. Chrome's own guidance is that "data is not immediately available in global variables since the previous context was torn down", so a module-level cache is rebuilt on every cold start and buys nothing. getMessage() is a synchronous lookup against a catalogue Chrome already parsed at load time; call it at the point of use.

How do I store a user's chosen language in Manifest V3?

Use chrome.storage. Chrome documents the constraint directly: "The web platform's Storage interface (accessible from window.localStorage) cannot be used in a service worker". Be aware that chrome.i18n.getMessage() always resolves against the browser locale and takes no locale argument, so a true in-extension language switcher requires catalogues you load and index yourself.

Can I fetch translation files at runtime from an MV3 service worker?

Not with XMLHttpRequest, which "can't be called from a service worker"; fetch() is the replacement and works fine. The better answer is usually to ship _locales/ so Chrome resolves strings for you — that removes the network dependency, the async initialization and the caching question, and it localizes your manifest fields as a side effect.

Which chrome.i18n methods are asynchronous?

getAcceptLanguages() and detectLanguage() are asynchronous and return promises; getMessage() and getUILanguage() are synchronous and return strings. In a service worker, start the asynchronous calls from inside the event handler that needs the result rather than at module scope, and return true from an onMessage listener if you respond after an await.

Ship _locales instead of fetching catalogues

Everything on this page gets simpler when the strings are already in the extension. LocalePack takes your source messages.json and returns a _locales ZIP — upload, pay once, download. No account and no subscription. Placeholders such as $PLACEHOLDER$ and $1 are preserved, and all 52 locales are valid Chrome _locales folder names.

Translate your messages.json into 52 locales →
← Back to Guides
LocalePack
Hướng dẫnQuyền riêng tưĐiều khoảnHỗ trợ

© 2025 LocalePack. Bảo lưu mọi quyền.

Dự án này được dịch bằng LocalePack logoLocalePack