A JavaScript helper is written as `function request(url, opts = DEFAULTS) { opts.retries -= 1; /* ... */ }`, where `DEFAULTS` is a module-level object literal. Each test passes when run alone, but the suite fails when the tests run together. What is happening, and how would you fix it?
answer
- passes alone, fails together
- the evaluation is fresh, the object is not
- const guards the name, not the contents
- check identity, not contents
- copy at the boundary, never mutate inputs
basics
~20 sEvery call that omits opts receives the same DEFAULTS object, not a copy, so opts.retries -= 1 mutates shared module state that accumulates across calls. Order-dependent failures follow. Fix it by never mutating a parameter and building a fresh per-call object instead.
solid answer
~50 sThe default initialiser is evaluated on every call, but it evaluates to the *same* object each time — it just re-reads the module-level binding. So every call that omits `opts` aliases one shared object, and `opts.retries -= 1` decrements module state permanently. Run one test and `retries` is 2; run the suite and the tenth test sees a negative number. The tell is order-dependent, isolation-passing failures, and you confirm it in one line by logging `opts === DEFAULTS`. The fix is to stop mutating a value you were handed: write `function request(url, opts = {}) { const cfg = { ...DEFAULTS, ...opts }; }` and use `cfg`. That gives each call its own object, and it also removes a second latent bug — a caller passing `null` gets `null`, because defaults trigger on `undefined` only.
code
javascript · 7 linesconst DEFAULTS = { retries: 3 };
function shared(opts = DEFAULTS) { return opts; }
function fresh(opts = { retries: 3 }) { return opts; }
console.log(shared() === shared()); // true — one object forever
console.log(fresh() === fresh()); // false — new object per callgo deeper
Recognise that the function is writing into an object it does not own, and that every defaulted call gets the same object rather than a copy. The safe habit is to never assign to a parameter's properties.
Explain both halves of the mechanism: the initialiser is evaluated per call but resolves to one shared object, and a property write through the parameter reaches that object. Show the fix as a fresh per-call merge and note that it is shallow.
Lead with the diagnosis. Order-dependent, isolation-passing failures point at shared mutable state; confirm with an identity check, then fix the ownership rule rather than resetting state between tests. Mention the null-argument path and the nested-object caveat.
Turn the incident into a rule: module constants and function inputs are read-only, copying happens once at a defined boundary, and defaults are values not shared handles. Decide how that is enforced — lint rules, frozen exported constants, review checklist — so the class of bug stops recurring.
## What the symptom is telling you Tests that pass in isolation and fail in a suite are, almost always, shared mutable state. The candidate causes are a module-level variable, a cached object, or — as here — a shared object reached through a default parameter. The distinguishing feature is *order dependence*: reorder the tests and a different one fails, or run them in a single worker and the failure appears while parallel workers hide it. ## The mechanism Two language facts combine into the bug. **First: default initialisers run per call, but this one produces no new value.** People often hear "defaults are evaluated at call time" and conclude each call gets something fresh. What is fresh is the *evaluation*, not the *result*. `opts = DEFAULTS` evaluates an identifier, and that identifier resolves to the same object every time. Compare: ```js const DEFAULTS = { retries: 3 }; function a(opts = DEFAULTS) { return opts; } function b(opts = { retries: 3 }) { return opts; } a() === a(); // true — one shared object forever b() === b(); // false — a new object literal per call ``` **Second: writing through a parameter writes through to the shared object.** `opts` holds a copy of the reference, not a copy of the object, so `opts.retries -= 1` lands on the very object the module constant names. The write persists after the call returns, because the object outlives the call. So call one sees `retries: 3` and leaves 2 behind; call two sees 2 and leaves 1; by call five the value is negative and the retry loop behaves in a way no single test can reproduce. Note what `const` did *not* do for you here. `const DEFAULTS` forbids rebinding the name `DEFAULTS`; it says nothing about writing properties on the object it refers to. ## Diagnosing it - **Reproduce the order dependence.** Run the failing test after the one that precedes it in the suite; if it now fails alone too, you have shared state, not a flaky assertion. - **Check identity, not contents.** `console.log(opts === DEFAULTS)` inside the function answers the question immediately: `true` means every defaulted call shares one object. - **Look for the write.** Search the function body for assignments to the parameter's properties. Normalisation code (`opts.timeout ??= 5000`, `opts.headers.accept = '...'`) is the usual culprit, because it reads as harmless tidying. - **Make the write loud.** Freezing the shared constant turns the silent corruption into a thrown TypeError at the exact offending line, since module code runs in strict mode. That is a diagnostic and a guardrail, not a substitute for fixing the function. ## The fix The rule to adopt is *do not mutate what you were given*. Build your own object and work on that: ```js const DEFAULTS = { retries: 3, timeout: 5000 }; function request(url, opts = {}) { const cfg = { ...DEFAULTS, ...opts }; cfg.retries -= 1; // writes to this call's own object // ... } ``` Three things improved at once. Each call gets a fresh top-level object, so nothing accumulates. The caller's own `opts` object is no longer written to either — the previous version would have corrupted a caller-supplied object exactly the same way. And callers can pass a partial object without restating every key. One caveat to state out loud: `{ ...DEFAULTS, ...opts }` is a **shallow** merge. If `DEFAULTS` contains a nested object such as `headers`, every call still shares that nested object, and `cfg.headers.accept = '...'` reproduces the original bug one level down. Either keep the defaults flat, or merge the nested levels explicitly. ## The second bug in the original signature `opts = DEFAULTS` fires only when the argument is `undefined`. A caller writing `request(url, null)` — easy when the options come from a config lookup that returns `null` for "nothing configured" — gets `null`, and the first property read throws. Defaulting to `{}` and merging removes the special case: `{ ...DEFAULTS, ...null }` is fine, because spreading `null` contributes nothing rather than throwing. ## The wider lesson A module-level object that any function may write to is shared mutable state, whatever you named it and whatever `const` suggests. In a test suite it shows up as order-dependent failures; in a long-running server it shows up as configuration that drifts over hours and cannot be reproduced from a fresh start. The discipline that prevents both is the same: treat inputs and module constants as read-only, and copy at the boundary where a value enters the function that intends to change it.
- Would changing the signature to `opts = { retries: 3 }` fix it?It fixes the shared-state bug, because an object literal in a default initialiser is constructed anew on every call that needs it. But it makes the defaults invisible to callers who pass a partial object: supply `{ timeout: 100 }` and `retries` is gone entirely, since the default was skipped as a whole. The merge form keeps both properties.
- You add `Object.freeze(DEFAULTS)`. What does the failure look like afterwards?The corruption becomes a loud TypeError thrown at the offending assignment, because module code is strict-mode code and a write to a frozen object throws there. That converts a silent, order-dependent test failure into a stack trace pointing at the exact line. Treat it as a guardrail that surfaces the bug, not as the fix — the function still needs to stop mutating its input.
- Why is a shallow merge sometimes not enough here?Because spreading copies references, not the objects they point to. If DEFAULTS has a nested `headers` object, every call's merged config holds the same `headers` object, so writing `cfg.headers.accept` mutates the shared one and reproduces the original bug a level down. Keep defaults flat, or merge nested levels explicitly.
- How would this bug present in a long-running server rather than a test suite?As drift. Configuration that is correct after a restart degrades over hours as each request decrements the shared value, so behaviour depends on uptime and traffic volume and never reproduces locally. Restarting "fixes" it, which is the signature of accumulating in-process state and a strong hint to look for a mutated module-level object.
saying these in an interview costs you the question
- Says const on the object prevents property writes
- Assumes a default initialiser creates a new object each call
- Blames test flakiness or async timing instead of shared state
- Fixes it by resetting the shared object between tests
- Thinks passing null as the argument triggers the default