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:
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.
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 value | What 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. |
| undefined | The 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.
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 emptyThe 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 enConfirm 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"
}
}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.
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:
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.
- •
messageNameis not a string. Passing a number, an object,null, or an accidentallyundefinedvariable. This shows up when keys are computed —getMessage(config.labelKey)wherelabelKeywas never set. - •More than 9 substitutions. The substitution mechanism runs from
$1to$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.
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.
$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
_localesas a static asset directory that is copied once.
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.
| Observation | Cause | Fix |
|---|---|---|
| Returns undefined, not "" | messageName is not a string, or more than 9 substitutions were passed | Log typeof messageName and the substitution array length at the call site |
| Every key is empty, including @@extension_id | The i18n subsystem is not resolving at all for this context or locale folder | Check getUILanguage() against your _locales folder names |
| One key is empty, the rest work | That key is absent from the default_locale catalogue, or misspelled at the call site | Print the catalogue key list and compare character by character |
| Empty only in one language, fine in English | That 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 strings | The 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 nothing | Catalogues are read at load time; the extension was never reloaded, or the build did not copy the file | Reload from chrome://extensions and verify the built output, not the source |
| $USER$ renders literally in the UI | The placeholder is referenced in message but has no entry in the placeholders block | Declare the placeholder with content and example, or use $1 directly |
| TypeError on chrome.i18n instead of an empty string | chrome.i18n is not available in that execution context | Run 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""andundefinedand logs the key. - •Return the key on failure in development. A button labelled
saveButtonis 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
messagevalues 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-BRbefore 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.