Singleton in Node and the Playwright worker model
A Singleton guarantees a single instance of a class and a global access point. In TypeScript it is often made not as a class with static getInstance(), but simply as an export of an object from a module, because a module is cached and executed once per process.
The key Playwright nuance: tests run in parallel in several worker processes (separate Node processes). Each worker loads modules anew. So:
- A singleton is "one instance per worker", not for the whole run.
- State written in one worker isn't visible in another.
- Code in
globalSetup runs in a separate process, so the variables it filled aren't visible to tests. Data must be passed through a file, environment variables or storageState.
// db.ts - a "singleton"
let pool: Pool | undefined;
export function getDb() {
pool ??= new Pool({ connectionString: process.env.DB_URL });
return pool;
}
With workers: 4 there will be 4 pools. That is fine, but someone has to close them, and an ordinary module has no teardown hook. It is even worse with mutable state: a currentUser in a global module that tests change. The order of tests in a worker isn't guaranteed after a retry, and a test failure and a worker restart resets the state.
In an interview: In Playwright each worker is a separate Node process, so a module-level singleton lives separately in each worker, and shared state between them is impossible. So I don't keep mutable global state, and I make expensive resources worker fixtures.
The alternative: a worker fixture instead of a Singleton
Everything candidates make a Singleton for (a DB connection, an API client, config) is better done in Playwright as a worker fixture. It gives the same "one instance per worker", but with a managed lifecycle.
export const test = base.extend<{}, { db: Pool }>({
db: [async ({}, use) => {
const pool = new Pool({ connectionString: process.env.DB_URL });
await use(pool);
await pool.end();
}, { scope: 'worker' }],
});
What we get:
- An explicit dependency: it is visible which tests use the DB.
- Guaranteed closing of the resource.
- Easy to replace in a separate file through a new
extend.
Unique data per worker can be built from workerInfo.workerIndex or test.info().parallelIndex. parallelIndex lies in the range from 0 to workers - 1 and is preserved when a worker restarts, so it is convenient to use for a pool of pre-created test accounts.
When a Singleton is acceptable: an immutable config read once from env. There is no state there that can be spoiled.
A typical mistake: "I made a Singleton for the browser so as not to open it in every test". Playwright already reuses browser at the worker level, and test isolation is given by a new context. A custom singleton browser breaks isolation and parallelism.
In an interview: Instead of a Singleton I make worker fixtures: the same one instance per worker, but with an explicit dependency and a guaranteed teardown. I leave Singleton only for immutable config.