Browser vs Node.js Module Resolution
How browser ESM and Node.js resolve modules differently, how to configure conditional exports for dual-environment packages, and how to validate resolution parity in CI pipelines.
Modern JavaScript ecosystems demand deterministic module loading across divergent execution environments. Understanding browser vs node.js module resolution is critical for library maintainers and platform engineers shipping dual-target packages. The foundational divergence between runtime resolution strategies dictates how environment-aware packaging must be architected. For a comprehensive baseline on how these systems interact, consult the Module System Fundamentals & Dual-Package Resolution guide before implementing cross-runtime configurations.
Core Resolution Algorithms Across Runtimes
Native browser ESM and Node.js employ fundamentally different lookup mechanics. Browsers strictly require explicit file paths and extensions for bare specifiers; omitting .js or .mjs triggers immediate 404 Not Found errors. Conversely, Node.js performs recursive node_modules traversal, respects package.json type declarations, and historically supported implicit extension resolution for CommonJS and ESM.
Bundlers like Vite, Webpack, and esbuild abstract these differences but introduce build-time resolution divergence. Relying on bundler magic without verifying native runtime behavior leads to fragile packages. Additionally, import.meta.url and dynamic import() behave differently: browsers resolve relative to the current document URL, while Node.js resolves relative to the executing file path.
// package.json
{
"name": "@scope/dual-target-lib",
"type": "module",
"imports": {
"#utils/*": "./src/utils/*.js"
}
}
<!-- index.html: Browser Import Map -->
<script type="importmap">
{
"imports": {
"@scope/dual-target-lib": "/node_modules/@scope/dual-target-lib/dist/index.js"
}
}
</script>
# Node.js CLI: Validate native ESM resolution without bundlers
node --experimental-import-meta-resolve --print "import.meta.resolve('@scope/dual-target-lib')"
️ HAZARD PREVENTION: Never rely on implicit extension resolution in browser ESM. Enforce
.js/.mjsin all source files. When parsing format-specific behaviors, review Understanding ESM vs CJS Module Formats to prevent silent fallback to CommonJS wrappers.
Conditional Exports & Runtime Targeting
The package.json exports field orchestrates environment-specific routing without duplicating package state. Resolution follows strict precedence: node, default, browser, import, require. Misordering these conditions forces Node.js to load browser-specific polyfills or DOM shims, causing ReferenceError or memory leaks.
Dual-entry points must map to identical module instances to prevent singleton desynchronization. When resolution paths diverge, you risk instantiating separate state containers for the same dependency, directly triggering the Navigating the Dual-Package Hazard.
// package.json
{
"exports": {
".": {
"node": {
"import": "./dist/node/index.mjs",
"require": "./dist/node/index.cjs"
},
"browser": "./dist/browser/index.mjs",
"default": "./dist/browser/index.mjs"
},
"./utils": {
"node": "./dist/node/utils.mjs",
"default": "./dist/browser/utils.mjs"
}
}
}
# Validate condition precedence in Node.js
node --conditions=browser -e "console.log(require.resolve('@scope/dual-target-lib'))"
️ HAZARD PREVENTION: Always place
nodeconditions beforebrowserordefaultto prevent Node from consuming DOM-dependent bundles. Use--conditionsflags during local testing to simulate target environments before publishing.
TypeScript Path Mapping vs Runtime Reality
TypeScript’s paths and baseUrl configurations are strictly compile-time directives. They emit zero runtime resolution logic. When moduleResolution is misaligned (node16 vs bundler vs node10), type checking passes while runtime execution fails due to unresolved specifiers.
Aligning TypeScript declaration outputs with package.json exports prevents missing type errors. Use tsc --emitDeclarationOnly alongside modern bundlers to generate accurate .d.ts maps that mirror runtime entry points.
// tsconfig.json
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"baseUrl": ".",
"paths": {
"#internal/*": ["./src/internal/*"]
},
"declaration": true,
"declarationMap": true,
"outDir": "./dist"
}
}
// vite.config.ts
import { defineConfig } from 'vite';
import { resolve } from 'path';
export default defineConfig({
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
formats: ['es'],
fileName: 'index',
},
rollupOptions: {
external: [/^@scope\//],
output: {
preserveModules: true,
preserveModulesRoot: 'src',
},
},
},
});
️ HAZARD PREVENTION: Never assume
tsconfig.jsonpaths resolve in browsers or Node.js. Use bundler plugins (e.g.,@rollup/plugin-alias,vite-tsconfig-paths) to rewrite paths during build, or alignmoduleResolution: "bundler"with explicitexportsmappings.
CI/CD Validation & Environment Parity Workflows
Deterministic testing pipelines catch resolution mismatches before production deployment. Matrix testing across Node LTS versions, Chromium, Firefox, and Safari ensures cross-environment parity. Snapshot testing resolved module paths detects silent fallback changes, while strict lockfile enforcement prevents dependency drift.
# .github/workflows/resolution-validation.yml
name: Module Resolution Validation
on: [push, pull_request]
jobs:
matrix-test:
runs-on: ubuntu-latest
strategy:
matrix:
node: [18, 20, 22]
browser: [chromium, firefox, webkit]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: 'npm'
- run: npm ci --ignore-scripts
- run: npx playwright install ${{ matrix.browser }}
- run: npm run test:resolution-snapshot
env:
BROWSER: ${{ matrix.browser }}
# Deterministic cache key generation for CI
echo "node_modules-$(sha256sum package-lock.json | cut -d' ' -f1)" > .cache-key
️ HAZARD PREVENTION: CI environment caching frequently masks resolution failures due to stale
node_modulesor lockfile drift. Implementnpm cistrict enforcement, clear cache directories onpackage.jsonchanges, and run resolution snapshot tests in isolated containers.
Migration Strategies for Dual-Target Architectures
Transitioning legacy monoliths to dual-environment resolution requires incremental adoption. Start by toggling package.json type: "module" and migrating entry points to .mjs extensions. Replace the deprecated browser field with standardized exports conditions to enforce explicit routing.
Handle global polyfills and DOM shims via conditional imports rather than global namespace pollution. Automated migration scripts can refactor import paths across large codebases while preserving runtime compatibility.
#!/usr/bin/env bash
# Automated path migration script (run in project root)
find src -name "*.ts" -o -name "*.js" | while read -r file; do
# Enforce explicit extensions on extensionless relative imports
sed -i "s|from '\(\./[^']*\)'$|from '\1.js'|g" "$file"
done
echo "✅ Import paths updated with explicit extensions"
// package.json (Post-migration)
{
"type": "module",
"exports": {
".": {
"import": "./dist/index.mjs",
"types": "./dist/index.d.ts"
}
},
"typesVersions": {
"*": {
"*": ["./dist/*"]
}
}
}
️ HAZARD PREVENTION: When untangling
type: moduleclashes in monorepos, reference Resolving Type Module Conflicts in Legacy Projects for safe incremental rollout patterns. Always validate polyfill injection boundaries to preventprocessorwindowleakage across runtimes.