process.env Not Propagating? It's the ESM Import Cache
A login script successfully refreshed the credential at runtime and wrote it into process.env. Business requests still return 401 β the constant they imported is still the stale value from process startup.
Encountered this while building a data-collection tool for a client β recording the root cause and the fix.
TL;DRβ
An ESM module evaluates exactly once; what you import is a read-only snapshot from load time β no later write to process.env ever reaches an already-imported constant.
// β cached at load time, stale forever after
import { AUTH_TOKEN } from './config.js'
// β
read the current value on every use
function getToken() {
return process.env.AUTH_TOKEN || ''
}
One-sentence rule: a value that updates at runtime must be read dynamically from process.env at the call site β never imported as a module-level constant.
Symptomsβ
Three files, three jobs: config.js exports config constants, login.js logs in and refreshes the credential, runtime.js sends business requests with it:
// config.js β central export
export const AUTH_TOKEN = process.env.AUTH_TOKEN || ''
// login.js β refresh after login
process.env.AUTH_TOKEN = newToken // runtime update
console.log('[login] token refreshed')
// runtime.js β business requests
import { AUTH_TOKEN } from './config.js'
fetch(url, { headers: { Authorization: `Bearer ${AUTH_TOKEN}` } })
// β 401: AUTH_TOKEN is still the startup-time value (or an empty string)
Logs say the token was refreshed; the request header carries the old credential. Print AUTH_TOKEN and process.env.AUTH_TOKEN side by side and they differ β process.env is current, the imported constant is not.
Root Cause: An ESM Module Evaluates Only Onceβ
Per the ESM specification, a module's code executes exactly once β from its first import until the process exits; every subsequent import receives the same cached module instance.
So import { AUTH_TOKEN } from './config.js' in runtime.js actually means: load config.js (evaluating export const AUTH_TOKEN = process.env.AUTH_TOKEN || '', which freezes the then-current process.env value into the constant) and bind that value into runtime.js's scope.
Two properties of that binding create the trap:
- Read-only: the imported binding in the consumer cannot be reassigned (ESM import bindings do point at the export's live binding β but
config.jsexports aconst, which will never hold a new value) - Decoupled from process.env:
process.env.AUTH_TOKEN = newTokenexecuted later bylogin.jsmerely sets a property on theprocess.envobject β the evaluation inconfig.jsfinished long ago, and no mechanism propagates that write back into the exported constant
In one sentence: process.env is a mutable runtime object, and export const X = process.env.Y is a one-time snapshot of one of its rows. The snapshot does not track the original.
The Fix: Read Dynamically at the Call Siteβ
Option 1 (smallest change): read the current value on use. Replace the imported constant with a process.env read:
// runtime.js
// import { AUTH_TOKEN } from './config.js' β remove
fetch(url, {
headers: { Authorization: `Bearer ${process.env.AUTH_TOKEN || ''}` },
})
Option 2 (many call sites): centralize in a getter. When usage is scattered, consolidate into a function that evaluates on call:
// config.js β export a function, not a constant
export const getToken = () => process.env.AUTH_TOKEN || ''
// runtime.js β evaluated at call time, always current
import { getToken } from './config.js'
fetch(url, { headers: { Authorization: `Bearer ${getToken()}` } })
Option 3 (many config keys): export an object, read by property. Property access is dynamic by nature:
// config.js
const env = {
get token() { return process.env.AUTH_TOKEN || '' },
}
export default env
// runtime.js
import env from './config.js'
env.token // reads process.env on every access
All three share one principle: defer evaluation from module load to every use.
The same tool hits a second env-file trap after packaging (userData directory, not cwd) β a sibling problem about when and where config is read, covered in Packaged Electron App Can't Read .env? It Lives in userData.
Watch out
dotenv behaves identically: dotenv.config() reads the .env file exactly once, at call time. Editing the file afterwards, or calling plain config again, does not update already-injected values (override: true does overwrite, but only for code that reads process.env afterwards β anything imported as a constant stays dead). For "I changed .env but nothing happened": first check whether the process restarted, then whether the value was imported as a constant.
FAQβ
How should Node.js environment variables be configured and read?β
Configure via a .env file with dotenv or system-level exports; read them dynamically as process.env.XXX everywhere in code. The key takeaway: process.env is the only channel that runtime updates propagate through β a constant imported at module top level is fixed at first load and never follows later changes.
Why do other modules still see the old value after process.env is updated?β
Because the consuming module says import { TOKEN } from './config.js' β that line evaluates once at first module load, copying the value of that moment into a local constant. Nothing afterwards, inside config.js or on process.env, propagates back into it. Read process.env.TOKEN at the call site instead to always get the current value.
Do I need to restart the process after updating the .env file?β
Restarting is the reliable answer: dotenv.config() reads the file exactly once, at call time. For in-process hot reloads you must call dotenv.config({ override: true }) again AND have every consumer read process.env dynamically β a single import-as-constant anywhere breaks the propagation chain.
CCLEE
Independent developer, 24 years in e-commerce, focused on grounding AI in real business scenarios.
Work with me