Back to Blog
NODE.JS · DEBUGGINGPublished Updated 5 min read

The Node.js Caching Bug That Shows Every User the Same Name

One user logs in and sees another user's name. No hardcoded value, no database problem, and it only reproduces after the second request. A walkthrough of shared mutable state in a cached Node.js object, and the one-line fix.

TypeScriptNode.js

Your login endpoint greets each user by name. It works on your machine, it passes review, it ships. Then support forwards a screenshot: a user in Dhaka logged in and got welcomed as Mazedul.

No hardcoded name sits anywhere in the codebase. The tests pass. Restart the server and the first login is correct again, so you can't reproduce it locally at all.

I lost most of a day to this bug. The cause is four characters of JavaScript that look harmless in review. Below I rebuild the exact code path that produces it, one requirement at a time, so you can watch the bug walk in through the front door.

Step 1: a welcome message

A Node.js API needs to return a welcome message after login. The first version hardcodes it.

typescript
type Message = { title: string; body: string };

function getWelcomeMessage(): Message {
  return {
    title: "Welcome",
    body: "Welcome to the Fun World!",
  };
}

The /login route calls it once the credentials check out.

typescript
app.post("/login", authMiddleware, (req, res) => {
  // Other login work happens here, such as setting the auth cookie.
  const message = getWelcomeMessage();
  res.send(message);
});

Step 2: add a second language

Next requirement: support Bangla alongside English. Translators need to edit this copy without touching the codebase, so the strings move into a locale file.

locales/messages.jsonjson
{
  "en": {
    "title": "Welcome",
    "body": "Welcome to the Fun World!"
  },
  "bn": {
    "title": "স্বাগতম",
    "body": "মজার বিশ্বে স্বাগতম!"
  }
}

Read the file, pick the entry that matches the user's language.

typescript
import { readFileSync } from "node:fs";

type Language = "en" | "bn";
type WelcomeMessages = Record<Language, Message>;

function getWelcomeMessage(language: Language): Message {
  const file = readFileSync("locales/messages.json", "utf8");
  const messages: WelcomeMessages = JSON.parse(file);
  return messages[language];
}

Step 3: stop reading the file on every request

That version hits the disk and parses the same JSON on every single login. The file never changes while the process runs, so read it once at startup and keep the result in memory.

typescript
// Read and parsed once, when the module is first imported.
const welcomeMessages: WelcomeMessages = JSON.parse(
  readFileSync("locales/messages.json", "utf8"),
);

function getWelcomeMessage(language: Language): Message {
  return welcomeMessages[language];
}

Reasonable code. It also contains the bug, already, before anyone writes the line that triggers it.

Step 4: personalise the greeting

Weeks later the client asks for the user's name in the greeting. Add a <name> placeholder to the copy and swap it out per request.

locales/messages.jsonjson
{
  "en": {
    "title": "Welcome",
    "body": "Hi <name>, welcome to the Fun World!"
  },
  "bn": {
    "title": "স্বাগতম",
    "body": "<name>, মজার বিশ্বে স্বাগতম!"
  }
}
typescript
app.post("/login", authMiddleware, (req, res) => {
  const message = getWelcomeMessage(req.user.language);
  // The replacement belongs inside getWelcomeMessage in real code.
  // It sits here to keep the two concerns visible side by side.
  message.body = message.body.replace("<name>", req.user.name);
  res.send(message);
});

Locally you log in as your test user, see your own name, and ship it. In production the first person to log in after each restart gets a correct greeting. Everyone after them gets that first person's name.

What actually happens

Two calls in a row show it without a server involved.

typescript
const first = getWelcomeMessage("en");
first.body = first.body.replace("<name>", "Mazedul");
// "Hi Mazedul, welcome to the Fun World!"

const second = getWelcomeMessage("en");
second.body = second.body.replace("<name>", "Hashnode");
// "Hi Mazedul, welcome to the Fun World!"  <-- wrong name

console.log(first === second); // true: the same object both times

The return welcomeMessages[language] line hands back a reference to the cached object, not a copy of it. Every caller receives a pointer to the one Message that was parsed at import time.

The first request assigns to message.body and overwrites the cache. The placeholder is gone from that point on, so replace finds nothing to substitute and the first user's name stays baked into the shared object until the process restarts.

That restart detail is what cost me the hours. Every attempt to reproduce it began with a fresh server and a single login, which is the one case that works.

Notice that the file-reading version in step 2 had no such problem. JSON.parse built a fresh object on every call, so each request mutated its own copy. Caching the parse result is what turned a private object into a shared one.

The fix: return a copy

Give each caller its own object. A spread covers a flat object like this one.

typescript
function getWelcomeMessage(language: Language): Message {
  return { ...welcomeMessages[language] };
}

A spread copies one level deep, so nested objects and arrays inside the cache would still be shared. For those, reach for structuredClone (built into Node 17 and up) or Lodash cloneDeep.

Freezing the cache turns the same bug into a loud one. Under strict mode the write throws instead of corrupting the next thousand responses.

typescript
const welcomeMessages: WelcomeMessages = Object.freeze({
  en: Object.freeze({ /* ... */ }),
  bn: Object.freeze({ /* ... */ }),
});

Where else this bites

Any module-level object in a long-lived Node process is shared by every request that touches it. The greeting cache above is the small version. The same shape shows up in:

  • a default options object that a helper merges request data into
  • an in-memory cache of database rows that a caller edits before rendering
  • a config or feature-flag singleton that one code path patches at runtime
  • a template object reused across a queue of jobs

Each one shares the fingerprint: correct on the first call after a deploy, wrong afterwards, and impossible to reproduce in a test that runs a single iteration. If a bug report reads like data leaking between users, check what your caches hand back before you check your database.

Never hand a caller a reference to an object you intend to keep. Return a copy, or freeze the original.

Work With Me

Building something complex like this?

I help teams design and ship robust, scalable applications — from multi-tenant architecture to AI integration and everything between. If your product needs a foundation it can grow on, let's talk.

Let's Work Together