DEV Community

Anas Sheikh
Anas Sheikh

Posted on

unstable_cache Can Silently Serve One User's Data to a Completely Different User

I wrote about how force-caching a user-specific fetch can leak data across users. There's a second, less obvious way to cause almost the exact same bug using unstable_cache, and it's worth covering separately because the mistake looks completely different on the surface, even though the end result, one person's data showing up for someone else, is identical.

How unstable_cache Actually Builds Its Cache Key

unstable_cache takes three arguments, the function to cache, a key array, and options like revalidate and tags. That key array is not just a label, it's a real part of how the cache determines whether two calls should share a cached result or be treated as genuinely different.

export const getUserDashboard = unstable_cache(
  async (userId: string) => {
    await connectDB();
    return Dashboard.findOne({ userId }).lean();
  },
  ['user-dashboard'], // the actual cache key
  { revalidate: 60 }
);
Enter fullscreen mode Exit fullscreen mode

Look closely at that key array. It's ['user-dashboard'], a single static string. The function itself takes userId as an argument, but userId never appears anywhere in the key. As far as the cache is concerned, every single call to this function, regardless of which userId gets passed in, shares the exact same cache entry.

What This Actually Does in Production

User A loads their dashboard. getUserDashboard('user-a-id') runs, hits the database, and the result gets cached under the key 'user-dashboard'. Sixty seconds later, User B loads their own dashboard. getUserDashboard('user-b-id') runs, but the cache doesn't check whether the argument matches anything, it just sees a valid, unexpired entry under 'user-dashboard' and returns it directly. User B gets User A's dashboard data, served instantly from cache, with no query ever hitting the database for their actual account.

No error. No warning. The function executed successfully both times, from the code's perspective. The bug is entirely in what the cache key does and doesn't distinguish between.

The Fix: Include the Actual Argument in the Key

export const getUserDashboard = unstable_cache(
  async (userId: string) => {
    await connectDB();
    return Dashboard.findOne({ userId }).lean();
  },
  ['user-dashboard'],
  { revalidate: 60 }
);

// Called like this, the userId needs to be part of what makes the cache entry unique
async function getDashboardForUser(userId: string) {
  return getUserDashboard(userId); // userId is a function argument, but NOT part of the cache key above
}
Enter fullscreen mode Exit fullscreen mode

The actual fix requires either including the dynamic value directly in the key array itself, which needs to happen dynamically since the key array is evaluated once when the cached function is created, or restructuring to key by the value explicitly:

// A correct pattern: generate a uniquely-keyed cached function per user
function getCachedDashboard(userId: string) {
  return unstable_cache(
    async () => {
      await connectDB();
      return Dashboard.findOne({ userId }).lean();
    },
    ['user-dashboard', userId], // userId is now part of the actual cache key
    { revalidate: 60 }
  )();
}
Enter fullscreen mode Exit fullscreen mode

Now the cache key genuinely differs per user, ['user-dashboard', 'user-a-id'] and ['user-dashboard', 'user-b-id'] are treated as entirely separate cache entries, exactly as intended.

Why This Is Easy to Miss in Review

The function signature looks completely correct. It takes userId as a parameter, uses it in the actual database query, returns the right shape of data. Nothing about reading the function's logic reveals the problem, since the bug lives entirely in the relationship between the function's arguments and the separately-defined key array, two things that are easy to mentally treat as automatically connected when they are not connected automatically at all.

The Actual Rule

Any value that makes two calls to the same cached function meaningfully different needs to be part of the cache key, explicitly. Not implied by being a function argument, not assumed because it's used inside the function body, explicitly present in the key array itself. If a cached function takes a parameter that affects which data comes back, and that parameter isn't reflected in the key, every call sharing that key range is at risk of returning the wrong caller's data.

Check Your Own unstable_cache Calls

Grep for unstable_cache across your codebase, and for each one, check whether the function's actual arguments, anything that changes what data comes back, are reflected in the key array. A cached function with a parameter and a completely static key array is exactly this bug, whether or not it's caused a visible problem yet.

grep -rn "unstable_cache" --include="*.ts" .
Enter fullscreen mode Exit fullscreen mode

If you've got unstable_cache calls with parameters in production, genuinely worth checking this today rather than assuming it's fine because nothing's visibly broken yet, this bug's whole danger is that it looks completely fine until you specifically test with two different users back to back. Drop what you find in the comments.

Get the templates: https://pixelanas.gumroad.com


Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751

Top comments (0)