LocalePack
ಕ್ರೋಮ್ಫೈರ್‌ಫಾಕ್ಸ್ಎಡ್ಜ್OperaSafariCWS ಲಿಸ್ಟಿಂಗ್
Vue.jsReact
Next.jsi18nextReact Native
ಡೆವ್ ಮಾರ್ಗದರ್ಶಿಗಳುಯಶಸ್ಸಿನ ಉದಾಹರಣೆಗಳು
Home/Guides/getMessage returns empty
August 8, 2026

chrome.i18n.getMessage() returns an empty string

The Chrome documentation states it plainly: “If the message is missing, this method returns an empty string ('').” No exception is thrown. Nothing is written to the console. Your UI simply renders a blank label and you are left guessing. This guide enumerates every distinct cause of that empty string, how to confirm each one, and how to fix it.

Why the failure is silent

chrome.i18n.getMessage() is a synchronous function that returns a string. It has no error channel — no callback with chrome.runtime.lastError, no rejected promise, no thrown exception for a missing key. The Chrome reference documents the behaviour in one sentence:

“If the message is missing, this method returns an empty string ('').”

That is the entire contract. A typo in a key, a locale folder Chrome refuses to recognise, and a catalogue you forgot to reload all produce the identical symptom: "". The only way to tell them apart is to check them one at a time, which is what the rest of this page is for.

Because the return value is a string, the empty result flows straight into your DOM without complaint. el.textContent = chrome.i18n.getMessage("save") on a missing key produces an empty button, not an error. If you want to be told about it, you have to check for it yourself.

First: is it "" or undefined?

These are two different failures with two different causes, and distinguishing them immediately halves your search space.

Return valueWhat it means
""The call was well-formed. Chrome looked the message up and did not find a usable value. This is a catalogue problem — the key, the value, the locale folder, or the reload.
undefinedThe call itself was rejected. getMessage() returns undefined if messageName is not a string, or if you passed more than 9 substitutions. This is a call-site problem — the catalogue was never consulted.

A useful consequence: if you are seeing undefined, stop reading the sections about messages.json. Nothing in your catalogue can produce undefined. Jump straight to the call-site section.

Both values are falsy, so getMessage(k) || fallback collapses them into one branch and destroys the distinction. When you are debugging, compare with === against "" and undefined separately.

A debugging snippet you can paste anywhere

Paste this into the DevTools console of whichever context makes the failing call — the popup inspector, the options page, the content script’s tab, or the service worker inspector on chrome://extensions. It reports the exact return value and separates the two failure classes.

function debugMessage(key, substitutions) {
  const value = chrome.i18n.getMessage(key, substitutions);

  if (value === undefined) {
    console.error("[i18n] " + key + " -> undefined");
    console.error("  The call was rejected before lookup.");
    console.error("  messageName was not a string, or > 9 substitutions were passed.");
    return value;
  }

  if (value === "") {
    console.warn("[i18n] " + key + ' -> "" (empty string)');
    console.warn("  Chrome resolved the lookup to nothing.");
    console.warn("  UI language:", chrome.i18n.getUILanguage());
    return value;
  }

  console.log("[i18n] " + key + " -> " + JSON.stringify(value));
  return value;
}

debugMessage("appName");
debugMessage("greeting", ["Ada"]);
debugMessage("@@extension_id"); // sanity check: this should never be empty

The last line is the useful control. @@extension_id is one of Chrome’s predefined messages and does not come from your catalogue. If it returns a value but your own keys do not, the i18n subsystem is working and the problem is your messages.json. If everything is empty, including the predefined message, suspect the locale folder or the load state instead.

Second snippet — read the catalogue Chrome actually shipped:

fetch(chrome.runtime.getURL("_locales/en/messages.json"))
  .then(function (r) { return r.json(); })
  .then(function (catalogue) {
    console.log("keys:", Object.keys(catalogue).sort());
    console.log("appName entry:", catalogue.appName);
  })
  .catch(function (e) {
    console.error("could not read the catalogue file:", e);
  });

An extension can always fetch its own packaged files, so no web_accessible_resources entry is required here. Two caveats: this reads the file on disk, not the catalogue Chrome parsed at load time, and you must edit the path if your default locale is not en. Still, a key that is absent from this output is definitively absent from the catalogue, and a catch that fires tells you the folder name is wrong.

Cause 1: the key is not in the default_locale catalogue

The ordinary case. Chrome walks the fallback chain — the user’s specific locale, then its parent language, then default_locale — and if the key is in none of them, the lookup ends with an empty string.

_locales/
├── en/
│   └── messages.json      ← default_locale; the key must exist HERE
└── de/
    └── messages.json      ← translations; may be a subset of en

Confirm it:

  • •Run the second snippet above and look for the key in the printed list. Do not grep your source tree — grep what was built and loaded.
  • •If you use a build step (a bundler, a framework, a ZIP script), check the output directory. A catalogue that never got copied into dist/ is the single most common version of this cause.

Fix:

{
  "appName": {
    "message": "My Extension",
    "description": "Shown in the toolbar and the Web Store listing"
  },
  "saveButton": {
    "message": "Save"
  }
}
Add the key to the default_locale catalogue first, always. Translated catalogues are allowed to be incomplete — Chrome falls back for keys they omit. The default catalogue is not: it is the end of the chain.

Cause 2: the key exists, but its message is an empty string

This is the trap that costs people an afternoon. A key that is present in the user’s locale with an empty message value ends the lookup there. Chrome does not treat an empty value as “untranslated” and it does not fall back to default_locale for that key.

// _locales/de/messages.json  ← the user's locale
{
  "saveButton": { "message": "" }     ← lookup stops here, returns ""
}

// _locales/en/messages.json  ← default_locale, never consulted for this key
{
  "saveButton": { "message": "Save" }
}

The fallback chain operates at the level of keys, not values. Once a key is found in a catalogue, that catalogue’s value wins — even when the value is a zero-length string.

Confirm it:

  • •Check chrome.i18n.getUILanguage() to learn which catalogue Chrome is reading, then open that catalogue rather than the English one.
  • •Switch Chrome’s UI language to your default locale and retest. If the string appears, the missing value is in the translated catalogue, not the default one.

Fix:

Delete the key entirely from the translated catalogue. An absent key falls back cleanly; an empty one does not. This is the rule to enforce in whatever produces your translated files — a machine translation pass that emits "message": "" for strings it skipped has quietly broken every locale it touched.

// _locales/de/messages.json — correct
{
  "appName": { "message": "Meine Erweiterung" }
  // saveButton omitted entirely → falls back to en
}

The same rule is covered from the manifest side in default_locale: rules and common errors.

Cause 3: the key name does not match the catalogue

A one-character difference between the string in your code and the key in your JSON is indistinguishable, at runtime, from the key not existing at all. Both produce "".

✗getMessage("save_button")
✓getMessage("saveButton")

Separator style drift — snake_case in the code, camelCase in the catalogue.

✗getMessage("popup.title")
✓getMessage("popupTitle")

Chrome message names are flat. There is no nested-key or dot-path lookup.

✗getMessage("saveButon")
✓getMessage("saveButton")

A plain typo. Nothing warns you; the label just renders blank.

Casing deserves particular care. Rather than relying on any leniency in the lookup, treat message names as exact-match identifiers: the string you pass to getMessage() should be character-for-character identical to the key in messages.json, including case. That practice is correct under every interpretation and costs nothing. It also keeps your keys usable from __MSG_name__ manifest substitutions and from any tooling that reads the catalogue.

Fix — remove the possibility of drift:

// messages.js — one module owns every key
export const MSG = {
  appName: "appName",
  saveButton: "saveButton",
  popupTitle: "popupTitle",
};

export function t(key, substitutions) {
  const value = chrome.i18n.getMessage(key, substitutions);
  if (value === "" || value === undefined) {
    console.warn("[i18n] unresolved key:", key);
    return key; // visible in the UI, instead of a blank
  }
  return value;
}

// call sites
t(MSG.saveButton);

Returning the key itself when the lookup fails converts a silent blank into a visible defect. You will find these in the first minute of manual testing instead of after a release. In TypeScript, deriving a union type from the catalogue turns the same class of bug into a compile error.

A static check over your catalogue and call sites catches these before they ship — see validating messages.json.

Cause 4: the locale folder name is wrong or unsupported

Chrome accepts a fixed set of locale codes under _locales/, written with an underscore rather than a hyphen. A folder outside that set is not an error — the reference is explicit:

“If you use an unsupported locale, Google Chrome ignores it.”

Ignored, not rejected. The extension loads, the folder sits there looking correct, and every key in it resolves to "" for the users it was supposed to serve — who instead silently get the default_locale strings, or nothing at all if the key is absent from the default catalogue too.

✗_locales/pt-BR/
✓_locales/pt_BR/

Hyphen instead of underscore. This is the most common form of the mistake.

✗_locales/zh-Hans/
✓_locales/zh_CN/

Chrome uses region-based Chinese codes, not script subtags.

✗_locales/en-us/
✓_locales/en_US/

The region part is uppercase. Match the code exactly as Chrome writes it.

The full list of accepted codes, and the traps in it, are covered in Chrome extension locale codes.

Confirm it:

  • •Log chrome.i18n.getUILanguage(). Compare its output to your folder names, character by character. A mismatch in separator or case is a mismatch.
  • •Run the fetch() snippet from earlier against the suspect path. If the file loads but the strings still do not resolve, the file is present on disk but the folder name is not one Chrome recognises.

Cause 5: the call site is invalid — you get undefined, not ""

Two conditions make getMessage() return undefined rather than a string. Neither has anything to do with your catalogue, and both are easy to miss because undefined also renders as nothing in most UI code.

  • •messageName is not a string. Passing a number, an object, null, or an accidentally undefined variable. This shows up when keys are computed — getMessage(config.labelKey) where labelKey was never set.
  • •More than 9 substitutions. The substitution mechanism runs from $1 to $9. Pass an array of ten or more and the call is rejected outright.
chrome.i18n.getMessage("greeting", ["Ada"]);        // → "Hello, Ada"
chrome.i18n.getMessage("noSuchKey");               // → ""        (catalogue miss)
chrome.i18n.getMessage(undefined);                 // → undefined (bad messageName)
chrome.i18n.getMessage(42);                        // → undefined (bad messageName)
chrome.i18n.getMessage("row", [
  "a","b","c","d","e","f","g","h","i","j"          // 10 substitutions
]);                                                // → undefined (max is 9)

If you genuinely need more than nine values in one string, the string is doing too much. Split it into several messages and compose them, or move the variable parts out of the translated text entirely.

A single substitution may be passed as a bare string or as a one-element array. Be consistent — mixing the two forms across a codebase is a reliable source of “works here, blank there” reports.

Cause 6: a placeholder is used but never declared

Named placeholders written as $name$ inside a message are only meaningful when a matching entry exists in that message’s placeholders block. Write one without the other and the entry is malformed.

// Broken — $USER$ is referenced but never declared
{
  "greeting": {
    "message": "Hello, $USER$"
  }
}

// Correct
{
  "greeting": {
    "message": "Hello, $USER$",
    "placeholders": {
      "user": {
        "content": "$1",
        "example": "Ada"
      }
    }
  }
}

Chrome does not surface a per-message diagnostic for this, so what you observe is either a literal $USER$ rendered in your UI or — if the entry was dropped during catalogue parsing — the same empty string this whole page is about. Rather than depending on which of those it is, treat an undeclared placeholder as a bug to be caught before load: it is never correct.

Numbered substitutions ($1 through $9) placed directly in the message string need no placeholders block. The block exists so that translators see a named, documented slot with an example instead of a bare positional marker — which is worth the extra lines in any message a human will translate. Keep the two styles distinct within a message; see the messages.json format explained for the full grammar.

Cause 7: the extension was never reloaded

Message catalogues are read when the extension loads. Editing _locales/en/messages.json on disk does not change what a running extension returns, and neither does refreshing the popup or the options page — those are just documents inside an already-loaded extension.

What actually picks up a catalogue edit:

  • •The reload control on the extension’s card at chrome://extensions.
  • •A framework dev server that rebuilds and triggers an extension reload — but only if its watcher is actually configured to watch your locale files. Several build setups treat _locales as a static asset directory that is copied once.
If a catalogue edit appears to have no effect, verify against the built output before you doubt the code. Run the fetch() snippet: if it prints the old value, your build did not copy the new file and the extension is behaving correctly on stale input.

What this symptom is not

Three neighbouring failures look similar in a bug report but are diagnosed completely differently. Ruling them out early saves time.

1. The extension does not load at all

If default_locale names a folder that is not present, Chrome refuses to load the extension and says so explicitly:

Could not load extension from ‘/path/to/my-extension’.
Catalog file is missing for locale en.

Same for the pairing rule between the manifest field and the directory:

Could not load extension from ‘/path/to/my-extension’.
default_locale is required if _locales/ is present.

These are loud, load-time failures with an exact message. If you are seeing one of them you do not have a getMessage() problem — you have a manifest problem, and default_locale: rules and common errors covers each one.

2. A TypeError instead of a blank string

Cannot read properties of undefined on chrome.i18n means the API is not present in that execution context at all. The usual cause is code that was injected into the page’s own JavaScript world rather than running as a content script, where extension APIs are not exposed. That is a context problem, not a catalogue problem.

3. A literal __MSG_appName__ in the UI

Manifest substitution only applies to specific localizable manifest fields, and only when default_locale is set. Seeing the raw __MSG_key__ token means the substitution never ran — a different mechanism from getMessage(), covered in __MSG_key__ in manifest.json.

Diagnostic table

Work down the table. The first row whose observation matches is your cause.

ObservationCauseFix
Returns undefined, not ""messageName is not a string, or more than 9 substitutions were passedLog typeof messageName and the substitution array length at the call site
Every key is empty, including @@extension_idThe i18n subsystem is not resolving at all for this context or locale folderCheck getUILanguage() against your _locales folder names
One key is empty, the rest workThat key is absent from the default_locale catalogue, or misspelled at the call sitePrint the catalogue key list and compare character by character
Empty only in one language, fine in EnglishThat locale's catalogue has the key with "message": ""Delete the key from the translated catalogue so it falls back
A whole locale is ignored, users see the default stringsThe folder name is not a supported Chrome locale code (e.g. pt-BR instead of pt_BR)Rename the folder to the exact code Chrome accepts
The edit to messages.json changed nothingCatalogues are read at load time; the extension was never reloaded, or the build did not copy the fileReload from chrome://extensions and verify the built output, not the source
$USER$ renders literally in the UIThe placeholder is referenced in message but has no entry in the placeholders blockDeclare the placeholder with content and example, or use $1 directly
TypeError on chrome.i18n instead of an empty stringchrome.i18n is not available in that execution contextRun the call from a content script, popup, options page or service worker

Making the failure loud in the first place

Every cause above shares one property: the runtime does not tell you. The durable fix is not to memorise the causes but to stop the empty string from reaching your UI unnoticed.

  • •Never call getMessage() directly. Route every call through a wrapper that checks for "" and undefined and logs the key.
  • •Return the key on failure in development. A button labelled saveButton is an obvious defect; a button labelled nothing is not.
  • •Diff your catalogues in CI. Every translated file should be a subset of the default one, with no empty message values and no keys the default catalogue lacks.
  • •Assert your locale folder names against Chrome’s list. A string comparison in a build script catches pt-BR before a user does.

Frequently asked questions

Why does chrome.i18n.getMessage() return an empty string instead of throwing?

Because that is the documented contract: "If the message is missing, this method returns an empty string ('')." getMessage() is synchronous and returns a string, so it has no error channel — no callback with chrome.runtime.lastError, no rejected promise, and no exception for a missing key. Detecting the failure is the caller's responsibility.

What is the difference between getMessage() returning "" and returning undefined?

An empty string means the call was valid and the lookup found no usable value — a catalogue problem. undefined means the call itself was rejected before any lookup happened: messageName was not a string, or more than 9 substitutions were passed. Comparing with === against both values, rather than using a falsy check, tells you which class of bug you have.

Does Chrome fall back to default_locale when a translated message is an empty string?

No. The fallback chain works at the level of keys, not values. If the key exists in the user's locale catalogue with "message": "", the lookup stops there and returns the empty string. To get fallback behaviour, omit the key from the translated catalogue entirely.

Do I have to reload my Chrome extension after editing messages.json?

Yes. Message catalogues are read when the extension loads, so refreshing the popup or options page is not enough. Use the reload control on the extension's card at chrome://extensions. If a dev server is involved, confirm it actually rebuilds and copies _locales — many setups treat it as a static directory that is copied only once.

Why does my _locales/pt-BR folder not work?

Chrome accepts a fixed set of locale codes written with an underscore, so the folder must be pt_BR. For an unrecognised code the documentation states: "If you use an unsupported locale, Google Chrome ignores it." The extension still loads and the folder is silently skipped, which is why every key in it comes back empty.

Can chrome.i18n.getMessage() be called from a Manifest V3 service worker?

Yes. getMessage() is synchronous and works in a service worker, which is an advantage there: there is no promise to await and no state to lose across a restart. What does not work in a service worker is DOM-based localization, since a service worker cannot access the DOM or the window interface.

Catalogues that cannot produce an empty string

LocalePack takes your source messages.json, translates it, and returns a _locales ZIP — no account, no subscription, pay once. Placeholders such as $PLACEHOLDER$ and $1 are preserved rather than translated, and untranslated keys are never emitted as empty message values. All 52 locales it ships are valid Chrome _locales folder names, so none of them can be silently ignored.

Translate your messages.json into 52 locales →
← Back to Guides
LocalePack
ಮಾರ್ಗದರ್ಶಿಗಳುಗೌಪ್ಯತೆನಿಯಮಗಳುಬೆಂಬಲ

© 2025 LocalePack. ಎಲ್ಲಾ ಹಕ್ಕುಗಳನ್ನು ಕಾಯ್ದಿರಿಸಲಾಗಿದೆ.

ಈ ಯೋಜನೆಯನ್ನು ಇದರೊಂದಿಗೆ ಅನುವಾದಿಸಲಾಗಿದೆ LocalePack logoLocalePack