It is common wisdom in the programming world to discourage the use of global variables.1
- It is bug-prone due to shared mutability across modules.
- It introduces implicit dependencies within interfaces that are not syntactically communicated.
- It may alter the behavior of dependents across invocations—even when provided with the same arguments.
I've recently come across a bug that humbled and reminded me about these pitfalls. Let me tell you a cautionary tale about global configuration objects.
DISCLAIMER: The following story is based on an actual bug in one of the projects that I work on, but several details have been altered and simplified.
A race condition?
The bug was initially simple. A user reported that generating a profile page in the app with ✨AI✨ in quick succession resulted in some components in the page persisting the old values from the previous ✨AI✨ generation.
"Simple," I naively thought to myself, "It's a classic case of a race condition." I hypothesized that the previous generation completed after the current one, which resulted in the old values overwriting the new ones in the database. The only problem was: I couldn't find the race condition.
Each ✨AI✨ enrichment is persisted as a single row in the database. Even if a race condition were to happen, the generated profile page should swap out the contents on a per-generation basis (i.e., one row as a whole). This was clearly not the case as the generated page interleaved new and old contents.
Sure enough, the database was intact. Each ✨AI✨ enrichment was indeed isolated from each other. No interleaving fields. No missing properties. No bad data. Nothing suspicious at all...
It's a front-end bug, then?
"Okay, surely the bug is in the front end," I thought to myself. I looked into the root component that renders the profile page starting with the props.
// A vastly simplified recreation of the page.
interface Props {
// From the database...
config: ProfileDetails;
}
export function Profile({ config }: Props) {
return (
<main>
<HeroBanner src={config.bannerUrl} />
<h1>{config.name}</h1>
<p>{config.description}</p>
</main>
);
}
Nothing seemed off as the page component was literally just rendering what was given to it. "The bug must be in the data loading," I deduced. After some console.log
debugging, I confirmed that the rows from the database were still intact. At this point, I was fairly confident that the persisted data was not at fault.
Despite my best efforts, I could not reproduce the bug. The data loaded from an enrichment row exactly matched the details rendered in the page. I mean... that's why race conditions are so difficult to debug, huh? At this point, I could only rely on my detective skills.
The dangers of default configurations
A couple hours passed when I finally raised my eyebrow at the call site of the Profile
component.
// @/profile
export const DEFAULT_PROFILE_DETAILS = {
bannerUrl: '...',
name: '...',
description: '...',
// ...
};
import merge from 'lodash.merge';
import { DEFAULT_PROFILE_DETAILS } from '@/profile';
import { getProfileDetails } from '@/db';
import { getSessionAuth } from '@/auth';
export default async function Page() {
const auth = await getSessionAuth();
const profileDetails = await getProfileDetails(auth);
const config = merge(DEFAULT_PROFILE_DETAILS, profileDetails);
return <Profile config={config} />;
}
Wait a minute. Zoom in... Enhance!
const config = merge(DEFAULT_PROFILE_DETAILS, profileDetails); // 💥
And there it was: the single line of code that cost me several hours of my life.
Let me fill you in with the details. The merge
utility here is Lodash's _.merge
. As its name suggests, merge
recursively merges one object with another. The key word is "recursive"; we can't use the spread syntax because that will only do a shallow merge.
Now, we want to merge the database-enriched profileDetails
into the DEFAULT_PROFILE_DETAILS
. This is because the ✨AI✨ prompt may possibly fail for some fields, so we need fallback values when that happens. Here is where the issue arises:
From the Lodash documentation: "this method mutates the
object
."
Because of this, it was totally possible (and documented!) that profile-specific details could leak into the DEFAULT_PROFILE_DETAILS
—thereby causing subsequent requests to read leaked values. 😱 Suddenly, this innocent bug escalated into a data privacy issue!
Aside on serverless environments
This still begs the question: why hasn't the bug come up regularly enough for anyone to notice for a long time?
One important thing to note is that the app is deployed in a serverless environment. That means function invocations are typically ephemeral. JavaScript objects that are created within a request only exist within the lifetime of that request. By design, subsequent requests spin up new instances (a.k.a. isolates) of the environment.
On paper, the DEFAULT_PROFILE_DETAILS
should be reconstructed for each request, which sidesteps the entire issue. However, cloud hosting providers can do clever performance optimizations like preserving the isolate across multiple requests to eliminate cold boot times.2
This is why the bug only manifests itself in "quick succession". There's a small non-deterministic window of opportunity where rendering a profile leaks details from a previous request.
Some mitigations and workarounds
The knee-jerk reaction is to axe the _.merge
utility. But to be fair, there are several mitigations.
// Merging into a newly constructed object...
const config = merge({}, DEFAULT_PROFILE_DETAILS, profileDetails);
// Merging into a newly cloned object...
const config = merge(structuredClone(DEFAULT_PROFILE_DETAILS), profileDetails);
// Refactoring to use factory functions...
const createDefaultProfileDetails = () => ({ ... });
const config = merge(createDefaultProfileDetails(), profileDetails);
ASIDE: using
Object.freeze
doesn't really solve the issue because it prevents the mutation of the object during the merge process (which is a behavioral regression!). Also, TypeScript would not save us from the bug anyway even if it were annotated asReadonly
due to the way_.merge
is typed.
The common thread in all of these mitigations is avoiding mutation in global configuration objects. In the end, I committed to replacing all instances of non-primitive global const
exports throughout the codebase into factory functions. In fact, banning non-primitive exports altogether was a safe bet.
Lesson learned: avoid non-primitive (e.g., objects, classes, maps, sets, regular expressions, functions, etc.) global exports if you can.
Top comments (5)
Great stuff as always @somedood ! I've been bitten by lodash
merge()
in the past as well, so I feel your pain 😅This isn't about avoiding non-primitive globals, which you can also mark as readonly in TS. This is about using a tunnel boring tool to drill a hole.
lodash
has no place in modern JS/TS code, it made sense back when I was a wee lad in the late 00s and early 10s, the APIs in browsers were weird, map/filter/reduce was niche and not supported most places without awful polyfills.Now it's a huge vector for bugs, unreadable code and just shit DX. Don't get me started on the fact that most bundlers just import every
lodash
module unless you explicitly import them separately, only a few bundlers actually tree shake the root import (sometimes, don't get me started on the sometimes).The take away here is to avoid
lodash
, if you need to rely on an over achieving library to do your business logic, you're letting prototype pollution, various other gross things AND UNINTENTIONAL BUGS into your code without any warning thinking your saving time. You shouldn't have to read the edge case docs on an API that's supposed to guarantee a result in plain english.Write this logic yourself! And if you're junior engineers/interns complain... go assign them a CSS positioning bug to keep them humble.
The
readonly
will not save you in all cases as it isn't recursive nor is it a runtime construct. It is only a compile-time construct enforced by TypeScript and notably not enforced by the JS engine. Good luck when one of your downstream consumers mutate yourreadonly
object. We need stronger guarantees if we insist on using global non-primitiveconst
exports.This is not a
lodash
-only issue. That is not the takeaway from the article. The core thesis is that global non-primitiveconst
exports have the pitfall that mutating them once mutates them for everyone because they're participate in reference semantics, not value semantics.This is unfortunately true for the CommonJS package
lodash
, but not for thelodash-es
alternative package, which leverages ES module semantics to be tree-shakeable.Now that's just antagonistic and unnecessary. I do not envy the engineers you've managed under your wing.
You're "knee jerk reaction" to removing the merge tool is actually correct, you don't need lodash to do this reliable anymore. Just use the spread operator or
Object.assign
... The "hot takes" in the article are misleading to people who'll run into this bug by following bad advice and relying on lodash to to simple JS things... Bad OP! BAD!Object.assign
does not always work because it is a shallow clone.