The console says Cannot use GraphQLObjectType "Query" from another module or realm. There is one realm. There is one process. Node loaded graphql-js twice.
The library did nothing careless. It ships both formats through conditional exports, and that responsible-looking shape is what splits it. Node runs two loaders with two separate caches.
Two loaders, two caches
Conditional exports look like this.
{
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"default": "./dist/index.cjs"
}
}
}The ESM loader keys its module map by fully resolved URL (file:///…/index.mjs). The CJS loader caches in require.cache, keyed by absolute path. They are different data structures in different module systems. When conditional exports hand each loader a different file, deduplication is impossible by construction. Two files, two module instances, two of everything inside: two class definitions, two module-level caches, two singletons that were supposed to be single.
import { Thing } from 'lib' // ESM loader → dist/index.mjs
const { Thing: T2 } = require('lib') // CJS loader → dist/index.cjs
new Thing() instanceof T2 // false. Same name, different module instance.Node documented this before most packages had an exports field. Then Sindre Sorhus committed to ESM-only and dragged a thousand packages with him, and node-fetch@3 followed this summer. The old problem now runs at scale.
The other failures are silent
graphql-js keeps that realm check on purpose. Other libraries fail without a message. A plugin registers itself in copy A and the core reads the registry from copy B. A WeakMap keyed on the shared class holds nothing.
The sneakiest trigger needs no second package. The import condition also matches dynamic import() from inside CJS. One file calls both require('pkg') and await import('pkg'), and it instantiates both copies by itself. Incremental migrations do exactly this.
Detection needs --experimental-import-meta-resolve:
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const cjs = require.resolve('graphql')
const esm = new URL(await import.meta.resolve('graphql')).pathname
console.log(cjs === esm ? 'single instance' : `split:\n ${cjs}\n ${esm}`)Three fixes, ranked
You maintain a library? Take them in order.
- Go ESM-only. The blunt fix. Your CJS consumers stay on the old major. This is the Sorhus route and it is honest.
- Publish CJS with an ESM wrapper. One source of truth. The
.mjsentry re-exports the CJS file, so both loaders converge on one instance:
// index.mjs
export { Thing, createThing } from './index.cjs'This works because of a machine you have never heard of: cjs-module-lexer. It is a Wasm static analyzer. Node runs it over CJS files at link time to detect named exports. It recognizes exports.name = … patterns. It cannot see dynamic ones:
// index.cjs
exports.Thing = class Thing {}
Object.defineProperty(exports, 'Hidden', { value: 7, enumerable: true })
// index.mjs
export { Thing } from './index.cjs' // works: statically detected
export { Hidden } from './index.cjs' // SyntaxError: no named exportIf your CJS build exports dynamically, re-export the default and destructure instead.
- Isolate the state. Both formats ship as real builds, and every singleton, registry and cache moves into one tiny CJS file both builds require. Instances differ, state does not. This is Approach #2 in the Node docs, example included.
You maintain an app? Pick import and stay there. When a transitive dep drags the CJS copy in anyway, npm ls <package> names the culprit.
The error was right about the other module. Dual publishing is two packages in a trenchcoat, and Node loads both.