Relative path handling fails for one reason more than any other: code assumes the same base path in every runtime.
If you searched for resolve relative path javascript, __dirname in esm, or import.meta.url path join, this guide gives you stable patterns by environment.
Quick answer
- Use
__dirnamein CommonJS. - Use
import.meta.urlin ESM. - Use
new URL("./x", import.meta.url)for module-relative assets. - Avoid using
process.cwd()for module-local files unless you explicitly want CLI working-directory semantics.
1) CommonJS in Node.js
Use __dirname for files relative to the current module.
const path = require("path");
const fs = require("fs");
const configPath = path.join(__dirname, "config", "service.json");
const configRaw = fs.readFileSync(configPath, "utf8");
When to use: legacy Node projects or codebases still on CJS.
2) ESM in Node.js
__dirname is not available natively in ESM. Use import.meta.url.
import { fileURLToPath } from "url";
import { dirname, join } from "path";
import { readFileSync } from "fs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const configPath = join(__dirname, "config", "service.json");
const configRaw = readFileSync(configPath, "utf8");
For simple asset references:
const schemaUrl = new URL("./schema/email.json", import.meta.url);
3) Browser modules
In browsers, there is no filesystem path API. Use URLs relative to the module/document.
const iconUrl = new URL("./assets/icon.svg", import.meta.url).href;
For script tags:
<script type="module" src="./app/main.js"></script>
Resolution is URL-based, not filesystem-based.
4) Deno
Deno uses ESM semantics by default, so import.meta.url is the standard pattern.
const templateUrl = new URL("./templates/report.md", import.meta.url);
const text = await Deno.readTextFile(templateUrl);
5) Bundlers (Vite/Webpack/Parcel)
Bundlers usually rewrite import paths at build time. Prefer module imports over manual string assembly:
import template from "./template.html?raw";
import logoUrl from "./logo.svg";
When you need dynamic resolution, verify your bundler's support for new URL(..., import.meta.url).
process.cwd() vs module-relative paths
This distinction prevents many production bugs:
| API | Base | Best use |
|---|---|---|
__dirname / import.meta.url | current file | loading module-owned assets |
process.cwd() | launch directory | CLI tools and repo-root workflows |
If a test runs from a different directory than local development, process.cwd() assumptions often break first.
Common path-resolution mistakes
1. Mixing URL and filesystem APIs incorrectly
Use fileURLToPath() when converting import.meta.url to a filesystem path in Node.
2. Building paths with string concatenation
Use path.join (filesystem) or new URL (URL contexts) to avoid separator bugs.
3. Assuming bundler behavior in server runtime
Code that works in Vite/Webpack can fail in plain Node runtime if path transforms are missing.
Reliability pattern for service teams
When JavaScript services process async email or webhook payloads, path bugs often appear in template loading, fixture files, or attachment handling.
Use this guardrail set:
- keep module-local assets resolved from module location, not working directory
- run integration assertions against real message flows in Email Sandbox
- add environment-consistent checks in Email Integration Testing
- validate async payload behavior with Email Webhooks
Final take
Path resolution is not one universal trick. It is runtime-specific. Choose one base-path model per context, document it, and enforce it in tests so local, CI, and production behave the same way.