loke.dev
Header image for ShadowRealms Are the Ultimate Sandbox

ShadowRealms Are the Ultimate Sandbox

Running untrusted third-party code without the overhead of a Worker or the security risks of a shared global object is finally possible with this native isolation primitive.

· 8 min read

Everyone tells you that eval() is evil and that Web Workers are the only way to safely execute third-party code. While that advice is rooted in decades of security trauma, it’s also fundamentally limiting. We’ve spent years building complex message-passing architectures or heavy iframe-based sandboxes just to run a simple plugin, when what we really needed was a way to stay on the same thread but in a completely different world.

Enter the ShadowRealm.

For a long time, the JavaScript ecosystem has lacked a middle ground between "run this code in my global scope and hope it doesn't delete my database" and "spawn a separate thread and deal with the asynchronous overhead of postMessage." ShadowRealms (currently a Stage 3 proposal) provide a native, synchronous isolation primitive that changes how we think about extensibility.

The Problem: The Global Scope is a Minefield

If you're building a platform that allows users to upload "mods" or "plugins"—think Figma, VS Code, or a CMS—you face a massive security hurdle. JavaScript was designed with a shared global object (window in the browser, global in Node).

If I run your untrusted script in my main thread, you can do some truly diabolical things:

// A "helpful" plugin that actually destroys the app
Array.prototype.map = function() {
    console.log("I stole your data:", this);
    return []; 
};

Suddenly, every library in my application is broken or leaking data. The traditional fix is to use a Web Worker. Workers are great because they have their own global scope and run on a separate thread. But Workers are asynchronous by nature. If you need to run a plugin that computes a value synchronously within a hot loop, a Worker is a performance killer due to the serialization overhead.

What exactly is a ShadowRealm?

A ShadowRealm is a distinct global environment. It has its own global object, its own built-ins (Array, Object, Promise), and its own execution context. However, it lives on the same thread as the code that created it.

Think of it like a parallel universe. It looks like yours, it has the same laws of physics, but nothing that happens there affects your world—unless you explicitly reach across the boundary.

Here is the basic syntax:

const realm = new ShadowRealm();

// Running code directly via a string
realm.evaluate('globalThis.myVar = "I am in the shadow realm";');

// The main realm's global scope remains untouched
console.log(window.myVar); // undefined

The "Callable Boundary" Rule

This is where ShadowRealms get interesting (and a bit tricky). You cannot just pass a complex object or a class instance into a ShadowRealm. If you could, the isolation would be an illusion because the objects would hold references across the boundary, leading to identity discontinuity and potential security leaks.

The API enforces a Callable Boundary. Only primitives (strings, numbers, booleans, etc.) and "callable" objects (functions) can cross over. When a function crosses the boundary, it is wrapped in a way that ensures it only executes within its original realm.

Working with importValue

The most powerful way to use a ShadowRealm isn't through evaluate, but through importValue. This allows you to grab a specific export from a module and bring it into your main realm as a wrapped function.

// plugin.js
export const transformData = (data) => {
    return data.map(item => item.toUpperCase());
};

// main.js
const realm = new ShadowRealm();
const transform = await realm.importValue('./plugin.js', 'transformData');

const result = transform(['apple', 'banana']); 
console.log(result); // ['APPLE', 'BANANA']

In this example, transform is a function in the main realm that, when called, executes the code inside the ShadowRealm. The data passed in and out is copied or wrapped, ensuring the environments stay separated.

Why this beats the Iframe Hack

Before ShadowRealms, the "clever" way to get a new global scope was to inject a hidden <iframe> into the DOM and grab its contentWindow. This is what libraries like realm-shim did.

But iframes are heavy. They come with an entire DOM implementation, CSS engines, and windowing logic. If you need 1,000 separate sandboxes (perhaps for a massive spreadsheet where each cell has its own logic), 1,000 iframes will crash your browser. A ShadowRealm is a "headless" environment. It’s just the JS engine without the baggage of the Web API.

Real-World Use Case: A High-Performance Plugin System

Imagine you are building a data visualization tool. You want users to write custom "filters" for a dataset of 100,000 points.

Using a Web Worker:
1. Serialize 100,000 points to JSON or a Buffer.
2. Send to Worker.
3. Worker processes.
4. Serialize result and send back.
5. Deserialize and update the UI.

The serialization often takes longer than the actual computation.

Using ShadowRealm:
1. Pass the data (if primitives) or a getter function across the boundary.
2. The code runs synchronously on the same thread.
3. The result is returned instantly.

// main-app.js
const realm = new ShadowRealm();

const userFilter = await realm.importValue('./user-plugin.js', 'filterFunction');

const hugeDataset = [/* 100,000 objects */];

// We can't pass the objects directly, so we pass a simplified version
// or iterate and pass primitives
const processed = hugeDataset.filter(point => {
    return userFilter(point.x, point.y); 
});

*Note: In the current proposal, even passing objects as arguments to the wrapped function requires them to be primitives. You'd likely pass point.x and point.y rather than the point object itself.*

The Identity Discontinuity Gotcha

One thing that trips up developers is that instanceof stops working the way you expect. Because each realm has its own version of Array, an array created inside the ShadowRealm is not an instanceof Array in the main realm.

const realm = new ShadowRealm();
const getInternalArray = realm.evaluate('() => []');
const arr = getInternalArray();

console.log(arr instanceof Array); // false!
console.log(Array.isArray(arr)); // true (Array.isArray is smarter)

I've spent hours debugging similar issues in Node's vm module. It’s annoying, but it’s a necessary trade-off for true isolation. If they shared the same Array.prototype, the sandbox would be porous.

When should you *not* use a ShadowRealm?

Despite how much I'm hyping them up, ShadowRealms aren't a magic bullet for every situation.

1. CPU-Intensive Tasks: Since ShadowRealms run on the main thread, they will block the UI. If you are doing heavy crypto or image processing, stick to Web Workers.
2. DOM Access: ShadowRealms have no access to the DOM. You can't pass a div into a ShadowRealm and have the plugin style it. You have to pass data back and forth and do the DOM manipulation in the main realm.
3. Legacy Support: As of writing, ShadowRealms are still a proposal. While you can use polyfills, you won't get the full security benefits of a native implementation until the engines (V8, SpiderMonkey, etc.) ship it.

The Security Aspect: Is it really safe?

Is it "Ultimate Sandbox" safe? Well, it’s as safe as the JavaScript engine’s ability to partition memory.

The biggest win here is the prevention of Prototype Pollution. In a standard JS environment, if I can touch a prototype, I can compromise the app. In a ShadowRealm, the plugin is playing in its own sandbox with its own toys. If it breaks its toys, yours remain intact.

However, you still need to be careful about what functions you pass *into* the realm. If you provide a function like readDatabase to the realm, you’ve just given the untrusted code a bridge to your sensitive data.

// DON'T DO THIS
const realm = new ShadowRealm();
const bridge = (cmd) => db.execute(cmd); 
// Now the realm can do whatever it wants to your DB

How to use it today

Since this is Stage 3, you can play with it in some environments using flags, or via the Salesforce/oz-realm polyfill ecosystem (though it's complex). If you're using V8 (Node or Chrome), keep an eye on the experimental flags.

In Node.js, the vm module has provided similar functionality for years (e.g., vm.runInNewContext), but vm is notoriously insecure against certain heap-escaping attacks. ShadowRealms are being designed with a much stricter security model in mind, intended specifically for the web.

A Mental Model Shift

We need to stop viewing JS environments as monolithic. The future of the web is highly modular. We are moving toward a world where your main application acts as an operating system, and the various components—ads, third-party analytics, user plugins, widget dashboards—run in their own isolated ShadowRealms.

It’s about least privilege. Why should a "Dark Mode" toggle plugin have the ability to read my localStorage or hijack my fetch requests? It shouldn't. By wrapping these extensions in a ShadowRealm, we enforce a contract: "You can compute things, you can return data, but you cannot touch anything I didn't explicitly give you."

Wrapping Up

ShadowRealms bring a level of architectural cleanliness that we've been missing. They allow us to stop over-relying on Workers for simple isolation and stop living in fear of globalThis pollution.

If you are building a plugin-heavy architecture, start looking at how your data flow fits into the "Callable Boundary" model. It's a different way of writing JS, but it's one that makes our applications significantly more robust and secure. The overhead of a Worker is a high price to pay for isolation; ShadowRealms finally offer a discount.