Web Platform · JavaScript Runtime

Realms, Cross-Realm Identity, and Errors That Cross Boundaries

Each global object carries its own set of intrinsics, so objects from an iframe or worker fail instanceof checks while remaining fully usable and holding their realm alive.

Every global object comes with a full set of built-in constructors and prototypes created alongside it. An iframe’s Array is not the parent’s Array; its Object.prototype is a different object; its Promise is a different function with a different prototype. The specification calls this bundle a realm, and makes it the unit of intrinsic identity. Code that assumes one set of built-ins per process, or even per tab, breaks in ways that look like type errors but are really identity errors.

The distinction between realm and thread matters here. A thread runs one event loop; a document and its same-origin iframes typically share that loop, so their tasks and microtasks interleave in a single order. Each document, however, has its own realm. Objects can therefore cross the boundary without any serialization — passing a reference through window.parent.someFunction hands over the same object — while the constructors that produced them remain distinct.

Two sets of intrinsics in one thread

A realm record holds, among other things, the global environment, the global object, and the intrinsic objects: Object, Function, Array, Promise, Error and the rest, plus the prototypes they install. Initialization happens once, when the realm is created, so the functions are shared by everything running in that realm but by nothing outside it.

Within one thread, several realms can exist simultaneously. Each iframe creates one. A dynamically inserted iframe that is later removed keeps its realm alive as long as anything reachable from the parent still references an object created there. A MessagePort to a worker connects two realms on different threads, with the boundary described in worker messages; a postMessage to a same-origin window usually connects two realms on the same thread. The scheduling consequences differ — separate threads have separate loops — while the identity consequences are the same.

Because the intrinsics differ, so do the prototype chains. An array created by the iframe’s Array has the iframe’s Array.prototype in its chain, which has the iframe’s Object.prototype at its end, and neither object is the parent’s. The array is still an array in every way that matters to the engine: it has the internal slot that array methods check.

Identity versus usability

Passing an object across a same-origin frame boundary preserves identity. parentObj === childObj is true when the child was given the parent’s object. What fails is the classification performed by instanceof, which reads the prototype chain and compares against a constructor’s prototype property. The chain contains the child’s Array.prototype, not the parent’s, so:

// In the parent document
const foreign = iframe.contentWindow.eval('[1, 2, 3]');

foreign instanceof Array;        // false — different Array.prototype
Array.isArray(foreign);          // true  — checks the internal slot
foreign.constructor === Array;   // false
Object.prototype.toString.call(foreign);  // '[object Array]'

Array.isArray is specified to work across realms precisely because it tests an internal property rather than a prototype. Object.prototype.toString works because it inspects an internal slot for the built-in tag. typeof works, since it does not consult prototypes. JSON.stringify works, since it walks own properties and does not care which realm’s Object they belong to.

The general lesson: any check that depends on a constructor identity is a realm-local check. x instanceof Map, x instanceof Date, x instanceof Error, x instanceof Promise — all of them are false for an object built by another realm’s constructor, and all of them are true only when the two objects happen to come from the same realm. Duck typing by shape, or brand checks through internal-slot-testing methods, survive.

Brand checks and their limits

Some built-in methods are generic: they accept any object with the right internal slots and operate on it regardless of prototype. Array.prototype.map, Array.prototype.slice, and the rest of the array methods are generic in this sense. Others are specified to require the right internal slot and throw a TypeError otherwise: Map.prototype.get on a non-Map, Date.prototype.getTime on a non-Date, RegExp.prototype.exec on a non-RegExp. These checks are what make it possible to validate a value without trusting its prototype chain.

The pattern Object.prototype.toString.call(x) returns a stable tag for built-in types and is a common cross-realm substitute for typeof on exotic objects; it is not reliable for user classes, which report [object Object] unless they define Symbol.toStringTag. That symbol is a second route to cross-realm classification if the sending side sets it, but it is user-controlled and therefore advisory rather than binding.

Symbol.hasInstance adds a hazard. A class can define a static [Symbol.hasInstance] that returns whatever it likes, and a library that patches instanceof this way changes the check for every consumer of that class, including code in other realms that happens to use the same constructor. Since the mechanism is invoked by the operator itself, no brand check on the object can undo it.

Errors are the worst case in practice

Error objects are the place where cross-realm identity produces the most confusing failures. An error thrown by code in an iframe and caught in the parent is a genuine object with a message, a stack, and a prototype chain ending in the iframe’s Error.prototype. err instanceof Error is false, so the conventional guard if (err instanceof Error) rejects a value that is, in every operational sense, an error.

The reliable test is structural: check that the value is an object, that typeof err.message === 'string', and that typeof err.name === 'string'. That treats subclasses, DOMException, and cross-realm errors alike. The name property is the only stable classifier for the built-in error types after a realm crossing, and it is writable, so it is a hint rather than a proof.

Cross-origin frames add a security layer on top. The error event delivered to the parent’s global handler for an exception in a cross-origin child carries the generic message “Script error.” with empty source and line information, because the details would leak information about another origin. The event still fires; the diagnostic content is suppressed. Structured error reports have to be arranged explicitly through postMessage, which the child’s own code must perform, since the browser will not describe the failure on its behalf.

Errors that cross a worker boundary do not travel as objects unless something sends them: an uncaught error in a worker is reported through an ErrorEvent whose message, filename, lineno and colno describe the failure. A postMessage that sends an actual Error uses structured serialization, which reconstructs an Error in the receiver’s realm with message and stack preserved, and with the receiver’s Error.prototype — so the same instanceof problem arises, but the object at least has the right internal slots.

Lifetimes: a detached frame is still a realm

Removing an iframe from the document detaches its browsing context but does not tear down its realm immediately. Any JavaScript reference to an object created inside it keeps the entire realm reachable, which means keeping its global, its intrinsic graph, and every document node it references alive. A listener registered on the parent’s global object that closes over a variable from the child is enough to retain the child’s document after removal. This is the mechanism behind the familiar detached-DOM memory growth in applications that create and discard frames.

The same holds in the other direction with workers. The Worker object itself is a parent-realm object, and a handler assigned to its message event closes over parent state that stays reachable for as long as the worker reference does; the worker’s own realm does nothing to weaken that. A transferred port received on the parent side behaves the same way. The lifetime rule is uniform: reachability across realms is ordinary reachability, and the boundary does not weaken it.

There is no API to enumerate realms or to ask which realm an object belongs to. window.frames lists the child browsing contexts, and a worker object gives access to its postMessage and termination, but the intrinsics are only reachable through the global object a reference points at. Code that needs to handle objects from multiple realms must therefore test structurally, avoid constructor comparisons, and be explicit about which realm’s constructors it uses when it creates values that will be handed back.

Promises and the scheduling question

A promise created in an iframe and awaited in the parent works, because await only requires a thenable and the promise’s own reactions are queued as jobs on the thread’s job queue, not on a per-realm queue. The value passed to the parent’s continuation is the iframe’s value, with the iframe’s prototypes, which circles back to the identity problem for whatever was resolved.

When the two realms are on different threads, ordering is not shared. A promise settled in a worker and a promise settled in the page have no relative order beyond what message delivery imposes, and a Promise.all over both resolves in whichever order the jobs happen to run. Nothing in the specification orders jobs across threads, so any protocol that depends on it has to impose the ordering itself through explicit sequencing.

References

  1. ECMAScript Language Specification — Realms — Ecma International
  2. Array.isArray() — MDN Web Docs — MDN Web Docs
  3. instanceof — MDN Web Docs — MDN Web Docs