Environment
@googlemaps/js-api-loader 2.1.3 (also 2.0.2, as vendored by vue3-google-map 0.27.2)
- Maps JS
v=weekly (served 3.66.6c), Chrome 154, Electron 138, and Firefox 156.
Bug
maps.googleapis.com/maps/api/js intermittently responds 200 with a body that calls the callback with an error instead of loading the API:
google.maps.__ib__({ "error": { "code": 503, "message": "The service is currently unavailable.", "status": "UNAVAILABLE" } });
We observed it in CI browser runs and measured it with curl from a separate network: ~1–4% of uncached requests, with or without a key, often in bursts. The error response has no caching headers, unlike the normal bootstrap. The same finding has been reported to the Maps JS API issue tracker: https://issuetracker.google.com/issues/566284337.
In dist/index.js, bootstrap() sets:
namespace[PENDING_BOOTSTRAP_KEY] = resolve; // ignores the callback argument
...
namespace[IMPORT_API_NAME] = (libraryName, ...args) =>
libraries.add(libraryName) && triggerBootstrap().then(() => namespace[IMPORT_API_NAME](libraryName, ...args));
When the callback carries an error, triggerBootstrap() resolves anyway. The .then then calls namespace.importLibrary, which is still this function, because the API never loaded. That call gets back the same resolved promise and chains again: an infinite microtask loop. The page's main thread never yields, memory grows several hundred MB/s, and the renderer is killed at the V8 heap limit. The importLibrary() promise never settles, so callers can't catch it, and error reporting never sees it.
Reproduction
Runnable repro: https://gist.github.com/mattico/360de14756ae2c96d912ebe475301194. It has a single-file browser page (no install, no API key) and a Node + happy-dom script, each with a control mode. Both replace the network with a stub that answers the bootstrap <script> the way the real endpoint does. The browser version, for reference:
<!doctype html>
<!--
Open this file in a browser (file:// is fine). No install, no API key.
repro.html the fake server returns the 503 error callback -> tab freezes
repro.html?ok control: the fake server returns the API -> resolves
-->
<meta charset="utf-8">
<title>js-api-loader error-callback repro</title>
<p id="status">Starting in 1s…</p>
<p>Heartbeat (stops when the main thread is stuck): <b id="beat">0</b></p>
<script type="module">
import { setOptions, importLibrary } from "https://cdn.jsdelivr.net/npm/@googlemaps/js-api-loader@2.1.3/+esm";
const serverOk = new URLSearchParams(location.search).has("ok");
const status = (text) => (document.getElementById("status").textContent = text);
let beats = 0;
setInterval(() => (document.getElementById("beat").textContent = ++beats), 100);
// Stand in for maps.googleapis.com: answer the bootstrap <script> the loader
// appends the way the real endpoint does, either with the API (control) or
// with the error callback it intermittently serves.
const append = document.head.append.bind(document.head);
document.head.append = (node) => {
if (!(node instanceof HTMLScriptElement && node.src.includes("/maps/api/js"))) return append(node);
setTimeout(() => {
if (serverOk) {
google.maps.importLibrary = async (name) => ({ name }); // the real API replaces the stand-in
google.maps.__ib__();
} else {
google.maps.__ib__({ error: { code: 503, message: "The service is currently unavailable.", status: "UNAVAILABLE" } });
}
}, 0);
};
setTimeout(() => {
setOptions({ key: "YOUR_API_KEY", v: "weekly" });
status(`importLibrary("maps") with the server ${serverOk ? "OK" : "returning the 503 error callback"}…` +
(serverOk ? "" : " The heartbeat should stop now and the tab freeze."));
importLibrary("maps").then(
(lib) => status(`resolved: ${JSON.stringify(lib)}`),
(err) => status(`rejected: ${err.message}`),
);
}, 1000);
</script>
repro.html: after 1 s the heartbeat stops and the tab freezes. In Chrome 154 that's 100% CPU with memory climbing until an out-of-memory crash.
repro.html?ok: control. The stub returns the API, and importLibrary("maps") resolves.
In Node (node --max-old-space-size=256 repro.mjs from the gist), the control resolves. With the error callback no heartbeat ever prints, and the process dies with Reached heap limit … JavaScript heap out of memory in under a second.
Against the real endpoint: load a page that calls importLibrary("maps") with the cache disabled and reload repeatedly. A few percent of loads freeze (assuming that maps.googleapis.com/maps/api/js continues to return errors at that rate).
Reproduced in desktop browsers by auto-reloading a page containing Google's documented snippet verbatim (the same loop this package vendors) (a unique throwaway URL parameter per load, so the HTTP cache never answers) until the error callback was served:
- Chrome 154: the tab froze at 100% CPU with memory climbing until it crashed with an OOM.
- Firefox 156: the web console logged an out-of-memory error; as far as we could tell the tab itself did not crash.
Suggested fix
- Treat an error payload as a failed load:
namespace.__ib__ = (r) => r?.error ? fail(r.error) : resolve(). Reset bootstrapPromise as onerror does, and remove the failed script, so the next call can retry.
This is safe: on success __ib__ is called with no arguments, and an invalid key or disallowed referrer still gets the normal bootstrap (those surface later via gm_authFailure, not __ib__). So an error argument only means the transient failure, and a retry can't loop on a configuration error. Retrying with a short backoff recovered 100% of affected loads in our testing (27 of 900 cold loads hit the error, and all loaded after one to three retries).
- Independently, never let the stand-in call itself after the bootstrap settles. If
namespace.importLibrary is still the stand-in, reject, or wait on a macrotask with a bound, rather than recursing through microtasks. That turns any future mismatch between the callback and the API install into an error instead of a frozen tab.
We're running a patch that does both and can open a PR if that's useful.
Environment
@googlemaps/js-api-loader2.1.3 (also 2.0.2, as vendored by vue3-google-map 0.27.2)v=weekly(served3.66.6c), Chrome 154, Electron 138, and Firefox 156.Bug
maps.googleapis.com/maps/api/jsintermittently responds200with a body that calls the callback with an error instead of loading the API:We observed it in CI browser runs and measured it with
curlfrom a separate network: ~1–4% of uncached requests, with or without a key, often in bursts. The error response has no caching headers, unlike the normal bootstrap. The same finding has been reported to the Maps JS API issue tracker: https://issuetracker.google.com/issues/566284337.In
dist/index.js,bootstrap()sets:When the callback carries an error,
triggerBootstrap()resolves anyway. The.thenthen callsnamespace.importLibrary, which is still this function, because the API never loaded. That call gets back the same resolved promise and chains again: an infinite microtask loop. The page's main thread never yields, memory grows several hundred MB/s, and the renderer is killed at the V8 heap limit. TheimportLibrary()promise never settles, so callers can't catch it, and error reporting never sees it.Reproduction
Runnable repro: https://gist.github.com/mattico/360de14756ae2c96d912ebe475301194. It has a single-file browser page (no install, no API key) and a Node + happy-dom script, each with a control mode. Both replace the network with a stub that answers the bootstrap
<script>the way the real endpoint does. The browser version, for reference:repro.html: after 1 s the heartbeat stops and the tab freezes. In Chrome 154 that's 100% CPU with memory climbing until an out-of-memory crash.repro.html?ok: control. The stub returns the API, andimportLibrary("maps")resolves.In Node (
node --max-old-space-size=256 repro.mjsfrom the gist), the control resolves. With the error callback no heartbeat ever prints, and the process dies withReached heap limit … JavaScript heap out of memoryin under a second.Against the real endpoint: load a page that calls
importLibrary("maps")with the cache disabled and reload repeatedly. A few percent of loads freeze (assuming that maps.googleapis.com/maps/api/js continues to return errors at that rate).Reproduced in desktop browsers by auto-reloading a page containing Google's documented snippet verbatim (the same loop this package vendors) (a unique throwaway URL parameter per load, so the HTTP cache never answers) until the error callback was served:
Suggested fix
namespace.__ib__ = (r) => r?.error ? fail(r.error) : resolve(). ResetbootstrapPromiseasonerrordoes, and remove the failed script, so the next call can retry.This is safe: on success
__ib__is called with no arguments, and an invalid key or disallowed referrer still gets the normal bootstrap (those surface later viagm_authFailure, not__ib__). So anerrorargument only means the transient failure, and a retry can't loop on a configuration error. Retrying with a short backoff recovered 100% of affected loads in our testing (27 of 900 cold loads hit the error, and all loaded after one to three retries).namespace.importLibraryis still the stand-in, reject, or wait on a macrotask with a bound, rather than recursing through microtasks. That turns any future mismatch between the callback and the API install into an error instead of a frozen tab.We're running a patch that does both and can open a PR if that's useful.