`dynamic-import`: InvalidStateError ("database connection is closing", IDBDatabase)
Author: evolrossCreated Sep 16, 2026Updated Sep 16, 2026
LabelsconfirmedType:BugSeverity:productionImpact:someProject:Dynamic Import
### Summary
Meteor 3.4.1, though this issue has been around for **years**, with several randomly closed tickets and many forum threads.
`packages/dynamic-import/cache.js` never guards its `db.transaction()` calls and caches its `IDBDatabase` connection promise forever. Browsers close a page's IndexedDB connections out from under it — iOS Safari on backgrounding/page freeze, Chrome under storage pressure or "Clear site data", Firefox private browsing. When that happens, two distinct failures follow:
**1. An unhandled rejection applications cannot catch (telemetry noise).**
`flushSetMany()` runs from a bare `setTimeout(..., 100)` roughly 100 ms after a module fetch completes, and calls `db.transaction(["sourcesByVersion"], "readwrite")`. On a closing/closed connection, `transaction()` throws synchronously:
```
InvalidStateError: Failed to execute 'transaction' on 'IDBDatabase': The database connection is closing.
```
The throw rejects the promise returned by `withDB(...)`, but `flushSetMany`'s return value is dropped by `setTimeout` — nothing holds that promise, so it surfaces as an **unhandled promise rejection** with no user frames in the stack. Error trackers (Monti APM in our case) report every occurrence. We collected a large batch of these from a single production event (~5,000 concurrent mobile clients; every participant join dynamically imports i18n translation files and then the phone typically gets locked/pocketed — landing squarely in the fetch→flush window).
**2. Every subsequent dynamic import fails, even with perfect connectivity (functional).**
`withDB` caches `dbPromise` permanently and only handles failures to *open* (falling back to `callback(null)` — the no-cache path). A connection that closes *later* stays cached forever, so `checkMany()`'s `db.transaction(...)` throws on every later dynamic import — and that rejection propagates into the `import()` promise itself, **before the network fetch is ever attempted**. After the browser force-closes the connection (e.g. the app was backgrounded on iOS and resumed), every dynamic import on that page fails until a full reload. The comment in `cache.js` describes the cache as "a transparent optimization for production performance"; in this state it is a hard dependency and a single point of failure.
Verified in `[email protected]` (Meteor 3.4.1) and unchanged on current `devel`:
https://github.com/meteor/meteor/blob/devel/packages/dynamic-import/cache.js — no try/catch around either `transaction()` call, no `onclose` handler, `dbPromise` never reset.
### Reproduction
Minimal app — note the cache only runs in **production** builds (`canUseCache` requires `Meteor.isProduction`), so use `meteor run --production`.
```bash
meteor create idb-close-repro
cd idb-close-repro
meteor add dynamic-import # if not already present
```
`client/main.js` (replace contents; the import specifiers must be static strings):
```js
const log = (m) => (document.getElementById("log").textContent += m + "\n");
document.body.innerHTML =
'import a import b';
document.getElementById("a").onclick = () =>
import("/imports/a.js").then(
(m) => log("OK: " + m.default),
(e) => log("IMPORT FAILED: " + e)
);
document.getElementById("b").onclick = () =>
import("/imports/b.js").then(
(m) => log("OK: " + m.default),
(e) => log("IMPORT FAILED: " + e)
);
```
`imports/a.js`:
```js
export default "module a";
```
`imports/b.js`:
```js
export default "module b";
```
#### A. The functional failure (`checkMany` path) — deterministic, desktop Chrome
1. `meteor run --production`, open the app, open DevTools.
2. Click **import a** → logs `OK: module a`. The `MeteorDynamicImportCache` IndexedDB database now exists and holds the fetched source (Application → IndexedDB).
3. With the page still open (do **not** reload), DevTools → **Application → Storage → Clear site data**. This force-closes the page's open IndexedDB connections — the same state a browser-initiated close leaves behind.
4. Click **import b** (a module not yet imported this page load).
**Expected:** `OK: module b` — the cache is an optimization; the fetch endpoint is reachable.
**Actual:** `IMPORT FAILED: InvalidStateError: Failed to execute 'transaction' on 'IDBDatabase': The database connection is closing.` No network request to `/__meteor__/dynamic-import/fetch` is attempted (verify in the Network tab). Every further dynamic import on this page fails the same way until reload.
#### B. The unhandled rejection (`flushSetMany` path)
1. Fresh profile or cleared storage; `meteor run --production`; DevTools Network throttling set to "Slow 3G".
2. Click **import a**. While the fetch is in flight, perform step A-3 (Clear site data).
3. The fetch completes, `setMany` schedules `flushSetMany` (+100 ms), whose `transaction()` throws.
**Actual:** the console shows `Uncaught (in promise) InvalidStateError: ... The database connection is closing.` originating inside `dynamic-import`'s cache, with no application frames. There is no API surface through which an application can catch or filter this.
In the field neither repro needs DevTools: iOS Safari force-closes IndexedDB connections when a page is backgrounded. A user who switches apps or locks the phone right after a page that dynamic-imports on load lands the close inside the fetch→flush window (variant B), and after resuming, variant A applies to every subsequent import.
For provenance of the exact message, the browser primitive in isolation:
```js
const req = indexedDB.open("x", 1);
req.onupgradeneeded = (e) => e.target.result.createObjectStore("s");
req.onsuccess = (e) => {
const db = e.target.result;
db.close(); // sets the "close pending" flag — the state a forced close leaves behind
db.transaction("s", "readonly");
// ❌ InvalidStateError: Failed to execute 'transaction' on 'IDBDatabase':
// The database connection is closing.
};
```
### Suggested fix
Degrade to the no-cache path on transaction failure — mirroring what `withDB` already does for open failures — and drop the dead connection so a later call can reopen it:
```js
function safeTransaction(db, mode) {
try {
return db.transaction(["sourcesByVersion"], mode);
} catch (error) {
// The browser closed the connection out from under us (page freeze /
// backgrounding on iOS, storage pressure, site-data clearing). Drop the
// dead connection so the next withDB call opens a fresh one, and degrade
// to the no-cache path — the cache is an optimization, not a dependency.
dbPromise = null;
return null;
}
}
```
- in `checkMany`: `var txn = safeTransaction(db, "readonly"); if (! txn) return sourcesById;`
- in `flushSetMany`: `var setTxn = safeTransaction(db, "readwrite"); if (! setTxn) return;`
Two small hardenings alongside:
- In `request.onsuccess`, register `event.target.result.onclose = function () { dbPromise = null; };` — the `close` event fires precisely on abnormal (browser-forced) closure, so the cache recovers proactively instead of waiting to trip over the dead connection.
- Terminate the timer-driven flush chain (`return withDB(...).catch(function () {})` in `flushSetMany`, or equivalent) so that put-request failures — the flavor already reported in #11562 — also stop surfacing as unhandled rejections that applications cannot catch.
Happy to open a PR along these lines if the approach looks right.
### Related
- #11562 — the same unhandled-rejection surface, `sourcesByVersion.put` flavor
- https://forums.meteor.com/t/failed-to-execute-transaction-on-idbdatabase-the-database-connection-is-closing-what/54287 — this exact error in the wild
- #10182 — dynamic-import failure behavior when the fetch path is unavailable
### Environment
- Meteor 3.4.1, `[email protected]` (code identical on `devel` at time of writing)
- Production `web.browser` builds; observed across Chrome, Safari (macOS and iOS), and Firefox
- Heaviest on iOS, where backgrounding force-closes IndexedDB connections
Source: meteor/meteor