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?
// 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');}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
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?
// 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');}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?
// ledger.mjsclass Ledger { entries = []; post(cents) { this.entries.push(cents); }}export const ledger = new Ledger();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?
// 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'));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?
// db.mjsclass Pool { constructor() { console.log('pool opened'); } query(sql) { return `rows for ${sql}`; }}export const pool = new Pool();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?
// presence.mjsexport const online = new Set();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?
// node_modules/stream-metrics/package.json{ "name": "stream-metrics", "exports": { "default": "./index.mjs" } }// node_modules/stream-metrics/index.mjsexport const live = new Set();Go deeper
Pro tips
- 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 ×max5 = 20 connections allowed. - Close what a reload leaves behind, or keep it on
globalThis. A module that opens connections at load should use theglobalThisguard 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" (sourcedoc/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 torequire('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 fromnode_modulesfolders), it is not a guarantee thatrequire('foo')will always return the exact same object, if it would resolve to different files". "By deleting a key value from this object, the nextrequirewill reload the module". Section "Loading ECMAScript modules usingrequire()", its history: "This feature is no longer behind the--experimental-require-moduleCLI 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.
Related lessons
Checked against the tools and docs as of 2026-10-13.