Skip to content

Singletons through the module cache

"One instance" means one per module cache: count the caches before you count the instances.

Predict first

Before you read: predict

ExamHall's API runs in development under dev.cjs, a model of its dev server: after each edit it drops the app's own files from require.cache, leaving node_modules, and requires the app again. A developer starts it with node dev.cjs, and the model makes 3 edits. db.cjs is an app file; exam-pool is a package:

// app.cjs
const db = require('./db.cjs');
const { shared } = require('exam-pool');
module.exports = { db, shared };
// db.cjs
const { Pool } = require('exam-pool');
module.exports = new Pool();
// node_modules/exam-pool/index.js
class Pool { constructor() { console.log('pool created'); } }
exports.Pool = Pool;
exports.shared = new Pool();

How many times does pool created print?

Code for this question
// dev.cjsconst appFiles = () => Object.keys(require.cache).filter((f) => f !== __filename && !f.includes('node_modules'));require('./app.cjs');for (let edit = 1; edit <= 3; edit++) {  for (const file of appFiles()) delete require.cache[file];  require('./app.cjs');}
Pick one

The 30-second version

A module-level instance is built when its file first runs and shared by every importer that uses the same module cache. Each worker thread and each process has its own cache, and the cache is keyed by resolved file. Reloading a module builds another; globalThis keeps one across reloads.

"One instance" means one per module cache: count the caches before you count the instances.

How it works

Step 1 of 11

One per cache. export const pool = new Pool() runs when the file first loads. Every importer using that module cache gets that object (node-module-systems step 2).
In the kitchen
  • everyone working the stall takes change from the one tin under the table → importers sharing one module cache share the instance (step 1)

Analogy: A weekend craft-market stall's cash float tin

At the market In Node
everyone working the stall takes change from the one tin under the table importers sharing one module cache share the instance (step 1)
the stall across the aisle has its own tin, however friendly the two stalls are each thread or process has its own cache and its own instance (steps 3–4)
the gazebo is struck and put up again in a new pitch; a helper opens a fresh tin, and the old one is still in a crate with cash in it a reload builds a new instance and leaves the old one open (step 6)
the stall keeps a lockbox in the van, and whoever sets up checks it before opening a tin the globalThis guard (step 7)

In real terms: before trusting a singleton, count the module caches that will load it.

Breaks down when: a helper can walk across the aisle and borrow change; between threads, only copies cross (step 5).

Contrasting case: in Python, a module-level object is also per process: gunicorn -w 4 gives four, and importlib.reload() builds a new one while old references keep the first.

Examples, run for real

4.1 ExamHall: a pool per edit in development

EXH-4020: ExamHall's lib/prisma exported new PrismaClient() from module scope. In development, the dev server reloaded the app after every edit by dropping its files from Node's module cache and requiring it again (dev.cjs, a model of that reload: require.cache entries deleted, "the next require will reload the module", Node docs). A Pool that prints a line from its constructor stands in for the client. MR !4320 applies Prisma's documented form: keep the client on globalThis outside production, and reuse it when it is there.

// db.cjs, MR !4320
const { Pool } = require('./pool.cjs');
const g = globalThis;
const pool = g.examPool || new Pool();
if (process.env.NODE_ENV !== 'production') g.examPool = pool;
module.exports = pool;
4.1 start + 3 edits, NODE_ENV=development (dev.cjs reload model)
  as shipped: module.exports = new Pool()                         pools opened 4
  MR !4320:   g.examPool || new Pool(), kept on globalThis          pools opened 1

Every reload ran db.cjs again (step 6); nothing closed the earlier pools. With the MR, each reload still runs the file, but finds the pool on globalThis before it reaches new (step 7).

4.2 What the MR does not change

In production the condition is false and nothing is stored: the module cache alone keeps one pool per process (step 8). Prisma's page adds that "in a serverless environment, each function creates its own instance of PrismaClient, and each client instance has its own connection pool": globalThis does not reach across instances (step 4), so size each pool for the number of instances.

Try it

Answer first, then check. Nothing is saved.

Question 1 of 6

A ledger's dev server reloads the app after each of 3 edits, as dev.cjs models it:

// app.cjs
const pool = require('./db.cjs');
module.exports = { pool };
// pool.cjs
class Pool { constructor() { console.log('pool opened'); } }
module.exports = { Pool };

The team tries three versions of db.cjs, each keeping the pool in a Map on globalThis, each run with NODE_ENV=development node dev.cjs:

// db.cjs, version 1
const { Pool } = require('./pool.cjs');
globalThis.pools ??= new Map();
const key = Symbol('ledger');
if (!globalThis.pools.has(key)) globalThis.pools.set(key, new Pool());
module.exports = globalThis.pools.get(key);
// db.cjs, version 2
const { Pool } = require('./pool.cjs');
globalThis.pools ??= new Map();
const key = 'ledger';
if (!globalThis.pools.has(key)) globalThis.pools.set(key, new Pool());
module.exports = globalThis.pools.get(key);
// db.cjs, version 3
const { Pool } = require('./pool.cjs');
globalThis.pools ??= new Map();
const key = { name: 'ledger' };
if (!globalThis.pools.has(key)) globalThis.pools.set(key, new Pool());
module.exports = globalThis.pools.get(key);

Which version prints pool opened exactly once?

Code for this question
// dev.cjsconst appFiles = () => Object.keys(require.cache).filter((f) => f !== __filename && !f.includes('node_modules'));require('./app.cjs');for (let edit = 1; edit <= 3; edit++) {  for (const file of appFiles()) delete require.cache[file];  require('./app.cjs');}
Pick one

Question 2 of 6

A ledger module and its tests:

// ledger.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ledger } from './ledger.mjs';
test('a charge posts one entry', () => {
  ledger.post(500);
  assert.equal(ledger.entries.length, 1);
});
test('a refund posts one entry', () => {
  ledger.post(-500);
  assert.equal(ledger.entries.length, 1);
});

node --test ledger.test.mjs fails the refund test. Run alone with --test-name-pattern=refund, it passes.

Which one change makes both tests pass in the full run?

Code for this question
// ledger.mjsclass Ledger {  entries = [];  post(cents) { this.entries.push(cents); }}export const ledger = new Ledger();
Pick one

Question 3 of 6

A ledger app depends on fx-rates 2.1.0 and on ledger-report, which asks for fx-rates ^1.4.0, so npm installed a second copy inside it:

// node_modules/ledger-report/index.js
const fx = require('fx-rates');
exports.rateFor = (code) => fx.getRate(code);
// node_modules/fx-rates/index.js
// fx-rates 2.1.0
const rates = new Map();
exports.setRate = (code, rate) => rates.set(code, rate);
exports.getRate = (code) => rates.get(code);
// node_modules/ledger-report/node_modules/fx-rates/index.js
// fx-rates 1.4.0
const rates = new Map();
exports.setRate = (code, rate) => rates.set(code, rate);
exports.getRate = (code) => rates.get(code);

The app runs node server.cjs.

What does the last line print?

Code for this question
// server.cjsconst fx = require('fx-rates');const report = require('ledger-report');fx.setRate('EUR', 1.08);console.log('report uses EUR rate', report.rateFor('EUR'));
Pick one

Question 4 of 6

A ledger reconciles two days in worker threads:

// main.mjs
import { Worker } from 'node:worker_threads';
import { pool } from './db.mjs';
for (const day of ['mon', 'tue']) new Worker(new URL('./reconcile.mjs', import.meta.url), { workerData: day });
// reconcile.mjs
import { workerData } from 'node:worker_threads';
import { pool } from './db.mjs';
console.log(workerData, pool.query(`entries ${workerData}`));

node main.mjs prints pool opened three times, then both days' rows.

Which one change leaves one pool in the whole process and still prints both days' rows?

Code for this question
// db.mjsclass Pool {  constructor() { console.log('pool opened'); }  query(sql) { return `rows for ${sql}`; }}export const pool = new Pool();
Pick one

Question 5 of 6

A chat service keeps who is online in one module, and two test files use it:

// join.test.mjs
import { test } from 'node:test';
import { online } from './presence.mjs';
test('two users join', () => { online.add('ana'); online.add('ben'); });
// lobby.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { online } from './presence.mjs';
test('the lobby starts empty', () => { assert.equal(online.size, 0); });

The team runs node --test join.test.mjs lobby.test.mjs, then node --test --test-isolation=none join.test.mjs lobby.test.mjs.

Which runs fail the lobby test?

Code for this question
// presence.mjsexport const online = new Set();
Pick one

Question 6 of 6

A streaming service counts live matches with a package that ships one ES module file:

// dashboard.cjs
const { live } = require('stream-metrics');
exports.count = () => live.size;
// server.mjs
import { createRequire } from 'node:module';
import { live } from 'stream-metrics';
const require = createRequire(import.meta.url);
const dashboard = require('./dashboard.cjs');
live.add('match-1');
live.add('match-2');
console.log('dashboard shows', dashboard.count());

The service starts with node server.mjs on Node 24.

What does the run print?

Code for this question
// node_modules/stream-metrics/package.json{ "name": "stream-metrics", "exports": { "default": "./index.mjs" } }// node_modules/stream-metrics/index.mjsexport const live = new Set();
Pick one

Go deeper

Pro tips
  1. Count the caches before sizing a pool. Before setting a pool's max, count how many module caches will build it: the main thread, each worker thread, each process, each serverless instance. When: adding worker threads or moving to serverless (steps 3–4). Verify (RAN, tips.mjs): a pool module imported by a main thread and 3 worker threads printed 4 constructions, 4 × max 5 = 20 connections allowed.
  2. Close what a reload leaves behind, or keep it on globalThis. A module that opens connections at load should use the globalThis guard in development; check with a log line in the constructor that an edit does not print it again. When: any dev server that reloads server code (steps 6–7).
Source
  • Node.js v24 API, Modules: CommonJS modules, sections "Caching", "Module caching caveats" and "require.cache" (source doc/api/modules.md, branch v24.x): https://nodejs.org/docs/latest-v24.x/api/modules.html#caching · fetched 2026-10-13 (sources/node-modules.md). The curriculum row's first source. "Modules are cached after the first time they are loaded. This means (among other things) that every call to require('foo') will get exactly the same object returned, if it would resolve to the same file" (step 1). "Modules are cached based on their resolved filename. Since modules may resolve to a different filename based on the location of the calling module (loading from node_modules folders), it is not a guarantee that require('foo') will always return the exact same object, if it would resolve to different files". "By deleting a key value from this object, the next require will reload the module". Section "Loading ECMAScript modules using require()", its history: "This feature is no longer behind the --experimental-require-module CLI flag". Valid as of 2026-10-13.
  • Prisma ORM 7, Database connections (the row's second source, "Best practice for instantiating PrismaClient with Next.js"): https://www.prisma.io/docs/orm/v7/prisma-client/setup-and-configuration/databases-connections · fetched 2026-10-13. "if the framework refreshes the module responsible for exporting PrismaClient, this can result in additional, unwanted instances of PrismaClient in a development environment" (step 6); "you can store PrismaClient as a global variable in development environments only, as global variables are not reloaded" (steps 7–8, MR !4320's form); "In a serverless environment, each function creates its own instance of PrismaClient, and each client instance has its own connection pool". Valid as of 2026-10-13.
  • Node.js v24 API, Test runner, section "Test runner execution model" (source doc/api/test.md): https://nodejs.org/docs/latest-v24.x/api/test.html · fetched 2026-10-13 (sources/node-test.md). Not named by the row; teaching only, every item is executed. "When process-level test isolation is enabled, each matching test file is executed in a separate child process"; "When process-level test isolation is disabled, each matching test file is imported into the test runner process". Valid as of 2026-10-13.
  • Node.js v24 API, Packages, section "Conditional exports" (source doc/api/packages.md): https://nodejs.org/docs/latest-v24.x/api/packages.html · fetched 2026-10-13 (sources/node-packages.md). Not named by the row; background only. "Using the "import" and "require" conditions can lead to some hazards". Valid as of 2026-10-13.
  • Node.js v24 API, Worker threads (sources/node-worker-threads.md, fetched 2026-10-13) is saved for reference; steps 3, 5 and 9 rest on the runs, not on a quoted passage.

Checked against the tools and docs as of 2026-10-13.