Skip to main content

4 posts tagged with "ESM"

View all tags

process.env Not Propagating? It's the ESM Import Cache

Β· 5 min read

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:

  1. Read-only: the imported binding in the consumer cannot be reassigned (ESM import bindings do point at the export's live binding β€” but config.js exports a const, which will never hold a new value)
  2. Decoupled from process.env: process.env.AUTH_TOKEN = newToken executed later by login.js merely sets a property on the process.env object β€” the evaluation in config.js finished 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

Cannot access before initialization in ESM? Implicit globals

Β· 7 min read

Clicking the "Extract this page" button in the browser extension throws Cannot access 'Hc' before initialization β€” while typecheck, build, and the regular test suite all pass, and the error never reproduces in local development.

I hit this while building an e-commerce automated data collection tool for a client β€” bulk-scraping product images, SKUs, prices, and reviews, cleaned and exported as structured data for inventory management and competitor analysis. "Extract this page" is the collection entry of that toolchain, and the content script module was what broke.

TL;DR​

A third-party classic script (vendored) contains an implicit global assignment (CandidateElement = function(...), assigned without declaration). Legal in a classic script β€” it silently creates a global β€” the line becomes a ReferenceError once the file is inlined into an ESM module graph that runs in strict mode. Module initialization aborts on the spot, and the namespace the downstream code receives through dynamic import sits in the temporal dead zone (TDZ). Fix: add one line var CandidateElement; at the top of the vendor file.

The symptom: green build, instant crash on click​

The error fires when the user clicks, not at page load:

TypeError: Cannot access 'Hc' before initialization

Three counterintuitive facts:

  1. typecheck passes β€” nothing wrong at the type level;
  2. build passes β€” the bundler does static analysis only, it never executes module code;
  3. regular tests pass β€” no test imports the module graph completely.

The failing identifier is Hc, a 2-letter minified name, not any business symbol. Hold that thought; it matters for identification later.

Root cause: how an implicit global breaks the ESM module graph​

The offending line comes from a third-party library, at reader-finder.js:878:

// classic script semantics: assign without declaring = create a global, legal
CandidateElement = function(e, t) { ... }

The failure chain has 4 steps, each enabling the next:

Step 1: legal under classic script semantics. The file originally loaded via a <script> tag; in sloppy mode, assigning without declaring silently creates a global variable β€” the original author relied on exactly that.

Step 2: a minefield once inlined into ESM. The file got inlined into the extension's ESM module graph, and ESM code always runs in strict mode β€” the implicit global assignment now throws a ReferenceError, and module evaluation of that vendor module aborts immediately.

Step 3: the break spreads along the module graph. The content script content.js evaluates its inline module graph and stalls at the vendor module: earlier modules got their message listeners registered, but later module facades never executed β€” the graph is left half-initialized.

Step 4: the dynamic import lands in the TDZ. When the user clicks the button, code loads the namespace facade through dynamic import. Because of the step-3 break, that namespace is in the temporal dead zone, and touching it throws Cannot access 'Hc' before initialization β€” Hc being the renamed internal binding of the module that never finished initializing.

This explains every observation: static checks stay green because they never execute module code; the error appears on click because the dynamic import lives inside the click handler; and the identifier is minified because the broken binding was renamed by the bundler.

For the other high-frequency dynamic import pitfall (module not found), see: Node.js ESM dynamic import says module not found? Check the file extension.

The fix: one var declaration at the vendor file head​

No third-party logic changes β€” just turn the implicit global into an explicit declaration by adding at the top of the vendor file:

var ReaderArticleFinder;
var CandidateElement;

The assignment changes from "create a global" to "assign to a declared variable", which is legal in strict mode. Module initialization completes, and the facade downstream code imports dynamically works as expected.

ReaderArticleFinder in the same file was already handled this way β€” the same library planted the same trap twice; the first got fixed, the second (CandidateElement) slipped through.

Verification: reproduce it locally with Vitest​

Before fixing, make it reproduce on demand β€” otherwise every check means a production deploy. Regular tests never reach this path, but importing the module directly in Vitest (jsdom environment) does:

import { describe, it, expect } from 'vitest';

describe('vendor reader-finder strict-mode', () => {
it('initializes the full module graph without a ReferenceError', async () => {
const mod = await import('./lib/vendor/reader-finder');
expect(mod).toBeDefined();
});
});

This test reproduced the error before the fix and produced a stack with real file line numbers (reader-finder.js:878) β€” far more actionable than a minified Hc from production. It turned green after the fix.

The complete verification runs the extraction chain end to end: the module graph initializes fully and extraction actually works β€” both must pass to call it closed.

Regression guard​

The reproduction case became a permanent smoke test (extractor.test.ts), plus a rule for the repo: any new classic script vendored into the project must pass this test, or an equivalent strict-mode check, before landing.

Watch out

  • Keep vendor files close to the original for upstream diffs; when adding a var declaration, leave a comment at the file head explaining why, so the next vendor update does not wash it away as a conflict.
  • Implicit globals rarely come alone: search the whole file for assign-without-declare patterns before declaring β€” the same file had 2 in this case.
  • Bundler choice is irrelevant β€” esbuild, rollup, same outcome β€” because strict-mode semantics are a language-level fact.

Quick identification for this kind of TDZ error​

Next time Cannot access 'xxx' before initialization shows up, two traits tell you whether it is the same species:

TraitThis problemOther TDZ problems
Failing identifierminified short name (Hc, Wt)business symbol (myConfig)
Timingon interaction (dynamic import)at module/page load
Static checksall greenoften caught (let/const redeclaration class)

Left column on both rows: suspect an implicit global in a vendored classic script β€” search for assign-without-declare and reproduce with a direct Vitest import. The other classic ESM migration error (CJS require ESM) is covered here: Node.js require nanoid throws ERR_REQUIRE_ESM? Alternatives after v5 went ESM-only.

FAQ​

Why does 'Cannot access before initialization' only appear at runtime when typecheck and build are green?​

Static checks and bundlers never execute module code, while the ReferenceError from an implicit global assignment fires only when the module actually initializes. If the offending module sits on a dynamic import chain, the error defers to the moment of interaction β€” in this case, clicking the extension button, with 0 errors at build time. Importing the module directly in Vitest (jsdom) reproduces it locally with real line numbers.

What does 'Cannot access xxx before initialization' have to do with the temporal dead zone (TDZ)?​

The namespace object returned by a dynamic import stays in the temporal dead zone once its dependency module's initialization broke, so touching any export throws. The giveaway is the identifier name: a 2-letter minified name like Hc instead of a business symbol means the break happened during module graph evaluation, not in your code.

How do I fix implicit globals in a vendored classic script?​

Declare each implicit global at the top of the vendor file with var (2 in this case: ReaderArticleFinder and CandidateElement) so the assignment targets a declared binding. One line removes the whole module-graph initialization break; run a strict-mode smoke test before any new vendor file lands.

CCLEE

Independent developer, 24 years in e-commerce, focused on grounding AI in real business scenarios.

Work with me

Node.js require('nanoid') Throws ERR_REQUIRE_ESM? Alternatives After v5 Went ESM-Only

Β· 5 min read

In a CommonJS project, require('nanoid') to generate a unique ID throws ERR_REQUIRE_ESM the moment the process starts, and it exits immediately.

Encountered this while building the ecommerce data collection tool for a client β€” a browser-side scraper that captures product images, SKUs, prices, and reviews in real time, then cleans and exports them as structured files. The server needed a stable traceId per request for cross-service log correlation.

TL;DR​

From v5 onward, nanoid is an ESM-only package, and CommonJS require() cannot load it β€” it always throws ERR_REQUIRE_ESM. If your project is still CJS, the simplest replacement is Node's built-in crypto.randomUUID(): zero dependencies, works in both CJS and ESM, and produces a standard UUID.

The Problem​

A perfectly ordinary import in a CJS project:

// server.js (CommonJS)
const { nanoid } = require('nanoid');

const traceId = nanoid();

It crashes on startup, the stack pointing at nanoid's entry file:

node server.js

internal/modules/cjs/loader.js:905
Error [ERR_REQUIRE_ESM]: require() of ES Module
/node_modules/nanoid/index.js from server.js not supported.

Instead change the require of index.js in server.js to a CommonJS module,
or use a dynamic import() call.

Note that this isn't an intermittent or environment-specific error β€” it's a deterministic crash. Once you're on v5, the CJS path simply does not work.

Root Cause​

In v5, nanoid completed its ESM-only migration: its package.json no longer ships a CommonJS entry, only ESM. Node's CommonJS loader, require(), is synchronous and cannot load an ESM module, so it throws ERR_REQUIRE_ESM.

This isn't a nanoid bug β€” it's the ecosystem's module-format evolution. More and more packages ship ESM-only (got v12+, node-fetch v3, uuid v7+ all do the same). As long as your host project is CommonJS, you'll hit the same wall with every one of them.

If you've also hit "module not found" with dynamic import(), that's the same ESM resolution rules at work β€” see Node.js ESM dynamic import can't find the module? Check the file extension.

Solution​

Three options, ordered by how little they cost to adopt.

When you're generating unique IDs, nanoid's core value is "short and unique." But as long as the ID doesn't need to fit in a URL or be aggressively shortened, a standard UUID is more than enough β€” and it's built into Node 14.17+, with zero dependencies:

// Works identically in CommonJS and ESM
const { randomUUID } = require('node:crypto');

const traceId = randomUUID();
// => '1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed'

This single change solves three problems at once:

  • Zero dependencies: no more third-party package, so its module format can never hold you hostage;
  • Format alignment: UUID is a universal cross-language, cross-service format, handy for log correlation and database primary keys;
  • CJS/ESM agnostic: node:crypto is built into Node and behaves the same under both module systems.

The only tradeoff is length β€” a UUID is 36 characters versus nanoid's default 21. For traceIds and primary keys that cost is negligible; only if you need it in a short link should you keep reading.

Option 2: pin nanoid v3​

nanoid's v3.x is the last major version that supports CommonJS, and require works directly:

// package.json β€” explicitly pin v3
{
"dependencies": {
"nanoid": "^3.3.7"
}
}
const { nanoid } = require('nanoid');
const id = nanoid(); // 21-char short ID

Good for when you genuinely want short IDs but can't migrate the project to ESM yet. The cost is staying on an old version and missing v5's later updates.

Option 3: async dynamic import​

If you must use v5, the only way in is ESM's async loader:

// In CommonJS, load the ESM package with dynamic import()
async function makeId() {
const { nanoid } = await import('nanoid');
return nanoid();
}

// The call site itself has to be async
const id = await makeId();

It works, but nanoid is fundamentally a synchronous ID generator β€” wrapping it in async/await forces async to propagate up the entire call chain, which is rarely worth it.

Caveats

  • This trap isn't unique to nanoid: uuid v7+, node-fetch v3, and got v12+ are all ESM-only, and require-ing them in a CJS project throws the identical ERR_REQUIRE_ESM. The way to tell is to check the target package's package.json for "type": "module" or an "import"-only entry.
  • crypto.randomUUID() requires Node 14.17+; on older runtimes, assemble one yourself with crypto.randomBytes(16).toString('hex').
  • Don't require('nanoid') in a CJS project while also import-ing nanoid in an ESM one β€” mixing them leaves both old and new copies in the dependency tree, making behavior much harder to predict.

FAQ​

Why does require('nanoid') throw ERR_REQUIRE_ESM in Node.js?​

Because nanoid has shipped only ESM artifacts since v5, and Node's CommonJS require() loads synchronously and cannot load an ESM module β€” it throws ERR_REQUIRE_ESM the moment it hits nanoid's entry. This is a hard boundary between the CJS and ESM module systems, not a configuration issue.

Can I still use nanoid v5 in a CommonJS project?​

Yes, but either load it asynchronously with await import('nanoid') (which forces the whole call chain async) or pin the version to v3.x, which is still CJS-compatible. If you only need a unique ID, Node's built-in crypto.randomUUID() is the simplest path β€” zero dependencies and supported under both module systems.

CCLEE

Independent developer, 24 years in e-commerce, focused on grounding AI in real business scenarios.

Work with me

Node.js ESM Dynamic Import Can't Find Module? Check the File Extension

Β· 3 min read

Encountered this issue while building a SaaS analytics platform for a client. Here's the root cause and solution.

TL;DR​

In Node.js ESM mode, import('./path/to/module') doesn't auto-resolve ./path/to/module.js. If the TypeScript build output is missing .js extensions, the module throws ERR_MODULE_NOT_FOUND. When this import is inside deferred logic (timers, conditionals), the app starts fine but crashes later β€” PM2 shows a climbing restart count.

Fix: Ensure all ESM dynamic imports include .js extensions, and automate this in the build pipeline.

Problem​

PM2 showed the app restarting continuously:

β”‚ name             β”‚ β†Ί    β”‚ status β”‚ uptime β”‚
β”‚ analytics-api β”‚ 9 β”‚ online β”‚ 28m β”‚

Error log repeated every few minutes:

Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/app/dist/domains/video/cleanup'
imported from /app/dist/server.js

But the file clearly exists:

$ ls dist/domains/video/
cleanup.js executor.js queue.js

Root Cause​

ESM doesn't auto-resolve file extensions​

Node.js CommonJS (require()) automatically tries .js, .json, and other extensions. ESM (import) does not.

// ❌ ESM can't find the module
import('./domains/video/cleanup')
// Node.js looks for: ./domains/video/cleanup (exact path, no extension)
// Actual file: ./domains/video/cleanup.js

// βœ… Must include .js extension
import('./domains/video/cleanup.js')

Deferred imports hide the problem​

The import was inside a deferred execution:

// server.ts β€” doesn't execute immediately on startup
import('./domains/video/cleanup.js').then(({ startCleanupScheduler }) => {
startCleanupScheduler(); // triggers seconds later
});

The app starts successfully (DB connection, port binding all fine). When the timer fires, the import fails β†’ process crashes β†’ PM2 restarts β†’ starts fine again β†’ timer fires again β†’ crashes again. This creates a crash loop.

Why did it work before?​

Previous deployment used a build script that included a post-build step to fix import paths. One deployment skipped this step and deployed raw tsc output β€” tsc doesn't modify import paths in output files.

Solution​

1. Write .js extensions in TypeScript source​

TypeScript officially recommends writing .js extensions even in .ts files:

// βœ… Write .js even in .ts source files
import('./domains/video/cleanup.js').then(({ startCleanupScheduler }) => {
startCleanupScheduler();
});

2. Automated post-build fix (recommended)​

Add an import-fixing script to the build pipeline:

{
"scripts": {
"build": "tsc && node fix-imports.js"
}
}

Core logic of fix-imports.js:

import { readFileSync, writeFileSync, readdirSync } from 'fs';
import { join } from 'path';

function fixImports(dir) {
for (const file of readdirSync(dir, { withFileTypes: true })) {
const fullPath = join(dir, file.name);
if (file.isDirectory()) {
fixImports(fullPath);
} else if (file.name.endsWith('.js')) {
let content = readFileSync(fullPath, 'utf8');
// Fix dynamic imports
const fixed = content.replace(
/import\(['"](\.[^'"]+)['"]\)/g,
(match, path) => path.endsWith('.js') ? match : match.replace(path, path + '.js')
);
// Fix static imports
const fixed2 = fixed.replace(
/from\s+['"](\.[^'"]+)['"]/g,
(match, path) => path.endsWith('.js') ? match : match.replace(path, path + '.js')
);
if (fixed2 !== content) {
writeFileSync(fullPath, fixed2);
}
}
}
}

The build pipeline automatically appends .js to all relative import paths, keeping TypeScript source extension-free while preventing module-not-found errors in ESM deployments.

Note

  • This only affects Node.js ESM mode ("type": "module" or .mjs files). CommonJS is unaffected.
  • Static imports (import ... from './foo') have the same limitation, not just dynamic import(); import hoisting also causes another common issue β€” dotenv runs after the import chain, leaving env vars undefined
  • Using tsx or ts-node in development won't show this error (they auto-resolve extensions), but node dist/server.js in production will fail.
  • PM2 crash loop signature: restart count (β†Ί) keeps growing, uptime never exceeds a few minutes.