Why Do export and import Behave Differently in Node.js ESM?

This article explores how various export syntaxes in ES modules affect the binding behavior of imported values in Node.js, demonstrating differences between named exports, default exports, destructuring, and circular dependencies with clear code examples.

Node Underground
Node Underground
Node Underground
Why Do export and import Behave Differently in Node.js ESM?

Export Only

An ESM module defines a variable and exports it:

export let thing = 'initial';
setTimeout(() => {
  thing = 'changed';
}, 500);

In main.mjs we import the binding in several ways:

import { thing as importedThing } from './module.mjs';
const module = await import('./module.mjs');
let { thing } = await import('./module.mjs');
const { thing: destructuredThing } = await import('./module.mjs');
setTimeout(() => {
  console.log(importedThing);
  console.log(module.thing);
  console.log(thing);
  console.log(destructuredThing);
}, 1000);

Running with Node.js 16.4.2 prints:

changed
changed
initial
initial

The first two imports keep a live reference to the exported variable, so they reflect the updated value, while the destructured bindings capture the value at import time.

Export Default

Changing the module to export a named binding and a default binding:

let thing = 'initial';
export { thing };
export default thing;
setTimeout(() => {
  thing = 'changed';
}, 500);

And importing:

import { thing, default as defaultThing } from './module.mjs';
import anotherDefaultThing from './module.mjs';
setTimeout(() => {
  console.log(thing);
  console.log(defaultThing);
  console.log(anotherDefaultThing);
}, 1000);

The output is:

changed
initial
initial

Only the named export remains a live reference; the default export captures the value at the time of export.

When the default export is a function, the behavior changes again:

export default function thing() {}
setTimeout(() => {
  thing = 'changed';
}, 500);

Importing and logging after a timeout yields changed because the default export is treated as an expression that exports a reference, not the original function.

If the function is defined first and then exported as default, the reference is stable:

function thing() {}
export default thing;
setTimeout(() => {
  thing = 'changed';
}, 500);

In this case the imported function is not affected by the later reassignment.

Summary of Key Points

// Export an object and keep a live reference
import { thing } from './module.js';
import { thing as otherName } from './module.js';
import * as module from './module.js';
const module = await import('./module.js');

// Destructuring breaks the live reference
let { thing } = await import('./module.js');

// These are all export‑by‑reference forms
export { thing };
export { thing as otherName };
export { thing as default };
export default function thing() {}

// Export only the current value
export default thing;
export default 'hello!';

Circular Imports

A typical circular dependency works:

// module.mjs
import { hello } from './main.mjs';
hello();
export function foo() { console.log('foo'); }

// main.mjs
import { foo } from './module.mjs';
foo();
export function hello() { console.log('hello'); }

This runs without error.

However, using export const can cause a "Cannot access xxx before initialization" error:

// module.mjs
import { hello } from './main.mjs';
hello();
export const foo = () => console.log('foo');

// main.mjs
import { foo } from './module.mjs';
foo();
export const hello = () => console.log('hello');

Switching to default exports avoids the problem because the default export creates a hidden binding that is hoisted:

// module.mjs
import hello from './main.mjs';
hello();
function foo() { console.log('foo'); }
export default foo;

// main.mjs
import foo from './module.mjs';
foo();
function hello() { console.log('hello'); }
export default hello;

Understanding these nuances provides useful discussion points for interviews.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Node.jsESMExportimportcircular dependencies
Node Underground
Written by

Node Underground

No language is immortal—Node.js isn’t either—but thoughtful reflection is priceless. This underground community for Node.js enthusiasts was started by Taobao’s Front‑End Team (FED) to share our original insights and viewpoints from working with Node.js. Follow us. BTW, we’re hiring.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.