Skip to content

importLibrary loops forever (tab freeze / OOM) when the bootstrap callback receives an error payload #1304

Description

@mattico

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

  1. 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).
  2. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions