Node.js interview questions: Modules and packaging

Module-system interviews have moved beyond remembering `require` versus `import`. Modern Node.js code crosses package boundaries, conditional exports, TypeScript stripping, loaders, caches, and tools that may resolve the same specifier differently. The strongest questions present a package that works in development and fails in production, or a dual package that quietly creates two copies of state. The interviewer wants a deterministic walk through format detection and resolution, followed by a packaging decision you can defend for real consumers.

This category turns those failure modes into follow-up chains. Opening answers name CommonJS and ESM; deeper prompts ask which `package.json` field wins, why a cache key changes, what Node does with TypeScript syntax, and how an exports map changes the public contract. Use the questions to practice tracing from the importing file to the exact file Node loads. Then state the compatibility surface, testing matrix, and migration cost. The fully unlocked sample shows how production concerns - cold starts, stack traces, build artifacts, and dependency expectations - belong in the answer.

Covered in Volume 2: Module resolution and packaging
Question 01Fully unlocked sample

So, CommonJS versus ES modules. Which of the differences actually bite you in production?

A strong answer treats CJS and ESM as two different load models, sync execution versus parse-link-evaluate, and says where that gap actually bites in prod

What an AI-prepared candidate might say

So CommonJS is require and module.exports, and it loads synchronously at runtime. ES modules are import and export, and those are static, which is what gets you tree-shaking and top-level await and that kind of thing. Node decides which format a file is from the nearest package.json "type" field, or from the .mjs and .cjs extensions. In practice the differences I've run into are, __dirname and __filename just don't exist in ESM, you have to derive them from import.meta.url. Interop is a little one-directional too, import can load CommonJS fine, but require loading ESM needs care, I think. And if you want conditional or dynamic loading in ESM you go through import(), which is async. ESM is the standard going forward though, most new packages ship it, so migration is mostly mechanical really. Update the syntax, fix the dirname thing, set "type": "module".

Senior

Every difference that actually hurts comes from the same root, the two formats load completely differently. CJS is just an API. require resolves the file, runs it right there at the call site, synchronously, and hands you whatever module.exports happens to hold at that moment. Stick a require inside an if branch and it runs lazily, or never. ESM is a language feature and it runs in three phases. The whole graph gets parsed first, which is why syntax errors show up before any code runs. Then linking, where imports and exports get wired up as bindings, that's why cycles resolve more gracefully. Evaluation comes last. Two production things fall straight out of that. Hoisting is the first. Every static import gets resolved and evaluated before a single statement in your file runs, doesn't matter where you put the import line, so side-effect ordering you used to control with statement position in CJS just quietly changes. Second is live bindings. An ESM import is a view onto the exporting module's binding, reassign the export and every importer sees the new value. A CJS consumer holds whatever was sitting on the exports object when it called require. Then you've got the environment gaps, __dirname and __filename versus import.meta.url, plus import.meta.dirname on current Node, require.cache versus no public cache handle on the ESM side, strict mode always on, top-level this being undefined instead of module.exports. And resolution gets stricter. Relative imports need real file extensions and an explicit /index.js, because ESM doesn't go probing the filesystem the way CJS extension-guessing did.

Staff

I rank these by what they cost in incidents. Startup-order regressions sit at the top. You take a CJS service that required config, then set up instrumentation, then connected to things, and after migration it's an ESM graph where every static import evaluates before the first statement of your entry file. So the APM agent comes up after the http module it was supposed to patch. Or a pool module reads process.env before config loads. These land as works locally, broken metrics in prod, and they take days to attribute. Second is the dual-package hazard. A dependency ships both formats, some old transitive dependency pulls it in with require, your code pulls it with import, and now it's loaded twice. Module-level state forks into two registries, instanceof fails across the boundary, your singletons aren't singletons anymore. Third, deep imports into a package that added an exports map in a minor release. Install goes fine, then at runtime you get ERR_PACKAGE_PATH_NOT_EXPORTED. So I treat a format migration as a deploy-risk change and plan it like one. I inventory which dependencies get consumed by which format, --trace-require-module=all will print every require(esm) path for you. I put a smoke boot in CI that asserts init order off a startup log line per subsystem. Env loading comes out of module code entirely, either --env-file or platform injection. And I ban module-level side effects that depend on other modules' side effects, because that's the actual root cause under every one of these ordering incidents. Where I land, one format per service if you can manage it, interop confined to a known boundary file, and require(esm) on modern Node as the escape hatch that lets a CJS codebase consume ESM-only dependencies without a rewrite.

Follow-up chain

  1. So dotenv.config() is literally line one of your ESM entry file, and a module still reads process.env too early. How does that happen?
  2. Okay, so what's the clean way to fix that?
  3. People throw around 'live bindings'. What does that actually mean in practice?
  4. Why does ESM make you write file extensions on relative imports when CJS never cared?
Question 02First answer included

Your code calls require('foo'). How does Node actually go and find that file?

Good candidates walk the lookup order, builtins, relative paths, the node_modules climb, exports versus main, and use it to explain duplicate-version bugs

What an AI-prepared candidate might say

First it checks whether it's a core module, like fs. If the string starts with ./ or ../ or /, it resolves relative to the file doing the require, so it tries the exact name, then it adds extensions, .js, .json, .node, and then it'll treat it as a directory and look for a package.json main or an index.js. Otherwise it's a bare specifier, a package name basically, and Node looks in node_modules, starting next to the requiring file and walking up the parent directories until it finds the package or hits the root. Inside the package, the package.json main field says which file is the entry point, or exports in modern packages. And results get cached, so requiring the same module twice gives you the same instance back. That caching is also why two packages can get different versions, I think, each one just finds the nearest matching node_modules.

Senior

The whole lookup order, extension probing and the directory climb included, the point where exports maps take over, and how symlink realpathing picks which copy you get.

Staff

Phantom dependencies and duplicate instances as failure classes, how to chase them down with require.resolve, and the CI guards that keep the tree honest.

Follow-up chain

  1. Say two copies of the same library land in your tree. What actually breaks when the thing runs?
  2. And how would you figure out which copy a given file actually got?
  3. Okay, and what changes once the package ships an exports field?
  4. How does something like pnpm, with all its symlinks, play with this resolution story?
Question 03First answer included

Talk me through Node's module cache. And when does the whole singleton assumption stop holding?

The strong answer knows the cache keys on the resolved real path, lists the ways the singleton assumption breaks, and walks a circular require step by step

What an AI-prepared candidate might say

So the first time you require something, Node loads it, runs it, and sticks the module object in require.cache, keyed by the resolved filename I believe. After that, every require of the same file just hands back the cached exports object, nothing re-executes. That's why the pattern of creating a database pool at the top of a module works as a singleton, everyone who imports it shares the one pool. Circular dependencies don't error or anything, the module that joins the cycle just gets whatever the other one has exported so far, so you can end up with missing properties if you call things too early. You can also delete entries from require.cache to force a re-load, that's how some hot-reload tools do it. And ESM keeps its own cache, but it's the same practical effect, one execution per module, shared exports.

Senior

What the cache really keys on, four different ways one file turns into two instances, and a circular require traced in order, down to which module ends up seeing the hole.

Staff

A doubled connection pool traced from first symptom to the lockfile fix, why cache-delete hot reload leaks, and the boot pattern that makes a singleton deliberate instead of lucky.

Follow-up chain

  1. So A requires B, and halfway through B turns around and requires A. What exactly lands in B's hands?
  2. Fair enough. How would you restructure things so the cycle's just gone?
  3. How does a pool that's supposed to be a singleton end up existing twice in one process?
  4. When you delete an entry from require.cache, what does that actually free up?
Question 04First answer included

Let's talk interop. ESM loading CommonJS, CommonJS loading ESM, how does each direction actually work?

A good answer gets into the actual mechanisms, cjs-module-lexer synthesizing named exports and the top-level-await limit on require(esm).

What an AI-prepared candidate might say

Importing CJS from ESM has basically always worked. The module's module.exports becomes the default export, and Node can usually expose named exports too, so import pkg from 'cjs-pkg' works and import { thing } from 'cjs-pkg' tends to work as well. The other direction was forbidden for years, require() on an ES module threw ERR_REQUIRE_ESM, and the workaround was dynamic import(), which gives you back a promise and works from CJS. But modern Node has largely removed that restriction, I think, so require() of an ES module works now in current versions. The friction that's left is mostly around default exports. Depending on how the package was authored or transpiled, you sometimes have to go through .default to reach the actual function, and that's where the double-default shape comes from, or something like that.

Senior

How cjs-module-lexer conjures named exports out of CJS, what require(esm) actually hands you back, and the one thing that still makes it throw.

Staff

A playbook for the day a dependency goes ESM-only, the TypeScript setting that silently compiles away your import() bridge, and the boundary-file habit that keeps interop from spreading.

Follow-up chain

  1. Named imports from one CJS package work fine, then the next package over they just fail. What's deciding that?
  2. Is there a pattern that's safe no matter which package you're dealing with?
  3. So require() of an ES module works now. What can still make it blow up?
  4. Why would TypeScript's commonjs output break an await import() of an ESM-only package?
Question 05First answer included

Tell me about the exports field in package.json. What does it actually change, and what's this dual-package hazard people talk about?

Strong candidates treat the exports map as a public API contract enforced at resolve time, and can spot the dual-package hazard from its symptoms alone

What an AI-prepared candidate might say

So exports is how a package defines its public entry points. Before that you had main, which named one default file and left every other path in the package open. exports replaces that with a map, . covers the root, subpaths like ./utils cover the rest, and conditions like import, require, node, and default pick different files depending on the consumer. Usually that means importers get an ESM build and requirers get a CJS build. And anything not listed in the map just can't be imported at all, which is how a package hides its internals. The dual-package hazard is, I think, the cost of shipping both formats. The same package can load twice in one process, once as CJS and once as ESM, and the two copies don't share state, so singletons break and instanceof checks fail. There's also an imports field that does the same mapping for a package's own internal aliases, with # prefixes.

Senior

How conditions match in key order, subpath patterns, the imports (#) map, and the exact mechanics that let one package quietly become two instances.

Staff

How to prove a double-load in a live process, the publishing choices that dodge the hazard, and why exports changes turned into a breaking-change vector semver never sees.

Follow-up chain

  1. Here's one. The app writes config through import, a plugin reads it through require, and the values come back empty. What happened?
  2. How would you actually prove there are two copies loaded?
  3. How do the conditions inside an exports entry get ordered and matched?
  4. And that imports field with the # prefix, what's that actually for?
Question 06First answer included

What does a lockfile actually guarantee you? And where do supposedly reproducible installs still fall over?

A strong answer knows exactly what a lockfile pins, where the gaps are, and can diagnose a 'deploy broke with no code change' from install mechanics

What an AI-prepared candidate might say

So package.json declares the version ranges, and the lockfile records what those actually resolved to, the exact version of every package, its download URL, and an integrity hash. That way a later install reproduces the same tree instead of re-resolving the ranges against whatever happens to be newest that day. You commit the lockfile, and in CI you run npm ci, which installs strictly from it. It deletes node_modules first, it fails if the lockfile and package.json disagree, and it never writes back to the lockfile. Plain npm install can update it, I think. For the ranges, caret accepts minor and patch updates, and tilde is patches only. Put together you get reproducible builds, basically, the same lockfile produces the same dependency tree and the same behavior on every developer machine and every deploy.

Senior

What those integrity hashes really pin, why npm ci exists at all, the 0.x caret trap, and the platform-conditional optional-dependency failure that only ever fires in CI.

Staff

A postmortem checklist for the deploy that broke with no code change, reviewing lockfile diffs at PR time, and pinning the runtime itself, the layer most teams forget.

Follow-up chain

  1. CI on Linux dies with 'Cannot find module @img/sharp-linux-x64', and every Mac on the team is fine. What kind of failure are you looking at?
  2. Okay. And what do you put in place so it doesn't happen again?
  3. That integrity field in the lockfile, what is it actually protecting you from?
  4. Why is npm install even allowed to touch the lockfile in the first place?
Question 07First answer included

Two-parter for you. What do you actually get on import.meta, and what happens to your module graph once somebody drops a top-level await into it?

The answer you want treats top-level await as a graph property, async evaluation spreading to every importer, and prices its startup and failure semantics

What an AI-prepared candidate might say

import.meta is the metadata object each module gets. import.meta.url is the module's file URL, and newer Node has import.meta.dirname and import.meta.filename, which give you the __dirname and __filename equivalents directly. Older code derives those with fileURLToPath. There's also import.meta.resolve('specifier'), which tells you the URL a specifier would resolve to without actually importing it, and it honors the package's exports map while it's at it. Top-level await is, well, awaiting at module scope, so a module can load config or open a connection before its exports count as ready, and Node evaluates the graph around that. I'd use it sparingly though. It's handy in scripts and small tools, but awaiting something slow during import can delay startup and kind of hide failures, so long-running services usually keep initialization explicit instead of pushing it into module load.

Senior

The whole import.meta surface, url doubling as cache identity, the synchronous resolve, the dirname/filename pair, plus the post-order async evaluation TLA forces on the graph.

Staff

Why TLA serializes your boot and turns dependency blips into crash loops, the explicit-init alternative, and the time-to-listen measurement that ends the argument.

Follow-up chain

  1. Say a shared module sits there doing await connectDb() at the top level. What happens to everything that imports it?
  2. And if that connect call rejects, then what?
  3. When would you still reach for fileURLToPath these days?
  4. Why would you trust import.meta.url over process.cwd() when you're locating assets?
Question 08First answer included

Peer dependencies and hoisting, specifically in a monorepo. How do those actually behave once things get big?

Good answers explain hoisting and peers as mechanisms with real failure modes, phantom deps and duplicate React, grounded in how resolution actually works

What an AI-prepared candidate might say

A peer dependency is the package saying, the host project supplies this, I just work alongside it. The classic case is a React component library, it has to use the app's React instead of shipping its own copy. And modern npm installs missing peers automatically, I think, and errors when the versions conflict. Hoisting is the package manager lifting shared dependencies up to the root node_modules so packages share one copy, and when versions conflict the extra copies stay nested down in the tree. Workspaces link your local packages into the root so they resolve like published ones. The problems that come up are basically two. Hoisting can't reconcile some versions, so you get duplicates, and two Reacts break hooks. And phantom dependencies, where your code imports a package it never declared but hoisting happened to place it at the root anyway.

Senior

Trace workspace links, peer ranges, and root placement to explain how one library can resolve beside two React installations.

Staff

The invalid-hook-call postmortem walked end to end, CI guards that catch phantoms and duplicates before merge, and the hoisted-versus-strict layout call argued with real costs.

Follow-up chain

  1. Your UI library throws an invalid-hook-call error in exactly one app of the monorepo, everything else is fine. What's the resolution story?
  2. Before you go touching ranges, which commands confirm the duplicate?
  3. So a package imports something it never declared, and CI stays green anyway. Why does that work, and when does it finally fall over?
  4. What do overrides and resolutions actually do for you, and what's the risk you carry while they're in place?
Question 09First answer included

You bump Node to a new major version. What happens to all your native modules?

Strong candidates can explain the ABI boundary, NODE_MODULE_VERSION versus Node-API, and run a Node major upgrade as an inventory and rebuild exercise

What an AI-prepared candidate might say

Native modules are compiled C or C++ that gets loaded into the process, and they're built against a specific Node version's headers. So a major upgrade changes the binary interface, and the compiled addons you already have stop loading, you get a version mismatch error until they're rebuilt. Which means running npm rebuild, or honestly just deleting node_modules and reinstalling. Most of the popular packages avoid the compile step by shipping prebuilt binaries, at install time a helper downloads the binary matching your platform and architecture and Node version. When there's no prebuild it compiles from source with node-gyp, which needs Python and a C++ toolchain lying around. The exception is packages built on N-API, or Node-API I guess it's called now. That interface stays stable across Node versions, so those addons keep working after an upgrade.

Senior

What a .node file really is under the hood, why the ABI number bumps every major, and how Node-API addons opt out of the entire rebuild cycle.

Staff

The inventory to run before upgrade day, why a lazy require turns a load failure into a request failure, and the Docker layer-cache trap that ships stale binaries.

Follow-up chain

  1. That NODE_MODULE_VERSION error, where does it actually come from?
  2. And why does a Node-API addon never run into it?
  3. Your image builds fine, then the addon won't load on Alpine in production. What axis did you forget about?
  4. Compiling at install time versus shipping prebuilt binaries, what's the real tradeoff there?
Question 10First answer included

Modern Node, TypeScript codebase. How would you actually run it in production?

A strong answer knows what native type stripping covers and where it stops, and defends build-artifact versus run-TS with startup and observability numbers

What an AI-prepared candidate might say

Modern Node just runs TypeScript files directly. It strips out the type annotations and executes the JavaScript that's left, so for a lot of projects you can literally do node app.ts, no loader, no build step. Type stripping has been on by default since Node 23.6, I believe. It doesn't type-check though, so you still run tsc --noEmit in CI for that part. And the older path still works fine, you compile in CI with tsc or esbuild and deploy plain JavaScript, which keeps production simple and fast. Tools like tsx and ts-node are still around, mostly for older Node versions and for the TypeScript features the built-in stripping doesn't handle. Either way, source maps or preserved positions keep your stack traces pointing at the actual TypeScript lines.

Senior

Why whitespace-preserving stripping keeps stack traces honest with no source maps at all, which syntax throws ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX, and what the compile cache saves.

Staff

The build-artifact versus run-TS call settled with time-to-listen numbers, the erasable-only lint gate, and where the compile cache should actually live in a container.

Follow-up chain

  1. Which TypeScript syntax makes native stripping throw, and how do you keep that stuff out of a codebase?
  2. This compile cache people keep mentioning, what does it actually cache?
  3. And in a container, where would you point NODE_COMPILE_CACHE?
  4. So if the runtime ignores types completely, where does the type checking actually live?

THE BASELINE GETS YOU THROUGH QUESTION ONE.

Raw Mode trains the follow-ups.

Unlock every Senior and Staff answer, every tree answer, the debug repos, hallucination drills, design scenarios, and the framework capstone.Get NodeBook Raw Mode