Skip to content

Runtime deviations

Ahead of user code the sandbox installs the standard library, and that library targets ECMA-429, Minimum common web API. §5 puts every API in scope, and it ends by saying what a runtime owes when it cannot follow one: “Where any runtime environment must diverge from a normative requirement for technical or structural reasons, clear documentation shall be provided. Documentation shall include both explanation and impact of deviation.” This page is that documentation. Any divergence with no row here is a defect, not a feature.

The isolate performs no I/O of its own: the host runs every HTTP exchange and hands the whole answer back at once, so no partial body ever exists inside the isolate. One isolate is also one realm, with no workers, no document and no origin, so a browser concept a specification is written against sometimes has no counterpart here. Most rows below follow from one of those two facts.

An absent name comes in two forms. Most are absent completely: typeof reads undefined, and a bare read throws a ReferenceError the way it does in any JavaScript. But a few are declared absences, where the name exists and the first touch fails with a text naming the reason and what remains possible. typeof reads a published name as if the capability were there, so code that detects capabilities by typeof meets that failure inside its guarded branch. Every such wording is wire contract and lives in the declared absences. The rows below say only which form an absence takes.

One declared absence has no row here. ECMA-429 asks for no cache storage, so publishing caches and refusing every call is no divergence at all. The persistence contract declares that refusal and explains it.

Architecture maps the runtime’s modules. Where a row states a bound on what the guest can reach, the caps on the crypto bridge among them, the threat model names the boundary behind it.

Interfaces ECMA-429 requires that this runtime does not carry

Section titled “Interfaces ECMA-429 requires that this runtime does not carry”
Absent Requirement Reason Impact
CompressionStream, DecompressionStream ECMA-429 §5.1, by way of the Compression Standard The host performs the HTTP exchange and applies content coding there, so no code inside the isolate ever compresses anything. Both constructors exist and throw a NotSupportedError, as a declared absence: new CompressionStream('gzip') and new DecompressionStream('gzip') alike. Content coding on the wire still happens, because the host applies it, so a program that never meant to compress by hand notices nothing.
MessageChannel, MessagePort ECMA-429 §5.1, by way of HTML §9.4.2 One isolate is one realm with no workers, so a port has no second endpoint to reach. A channel inside a single realm is left with macrotask scheduling alone, and the timers already do that. new MessageChannel() throws ReferenceError: MessageChannel is not defined. The constructor is the earliest point a program can learn. A channel that existed and never delivered would surface later and read as a hang.
The whole WebAssembly namespace ECMA-429 §5.1 and §5.2, by way of the WebAssembly JavaScript Interface A WebAssembly memory is allocated where the isolate’s memory limit cannot meter it, and no arrangement inside the isolate brings it back under the meter. typeof WebAssembly reads undefined, so code that feature-detects takes the branch it already has. Code that assumes the namespace throws ReferenceError: WebAssembly is not defined at the first reference.
Blob.prototype.textStream, Body.textStream File API §3.3.6, Fetch §5.3 Both members were added to their standards long after the interfaces they hang on, and no property of the isolate accounts for their absence. new Blob(['hi']).textStream() throws a TypeError, so code written against a runtime that does have them, Node among them, fails at the call. stream().pipeThrough(new TextDecoderStream()) reads the same body with what this runtime publishes.

V8 hands an isolate memory the embedder’s allocator never sees along three paths, and WebAssembly is one. The other two are the growth of a resizable ArrayBuffer and the growth of a growable SharedArrayBuffer, both ECMA-262 members and neither anything ECMA-429 lists. Any of the three holds gigabytes under a 128 MiB limit. The engine enforces the memory limit on the metered heap alone, so all three paths are closed.

But the handshake closes the two growth operations differently from WebAssembly. ArrayBuffer.prototype.resize and SharedArrayBuffer.prototype.grow stay and throw, because no code feature-detects a method before calling it and a missing method would say only that a method is missing. Each throw names the reason and tells the guest what works instead: asking for the length up front. A buffer created at the length it needs is metered like anything else, and ArrayBuffer.prototype.transfer is untouched for the same reason. All three closures run in the handshake and not in the snapshot, and Sandbox runtime says why. The threat model says what these closures are for, and the memory family of the benchmark proves each path twice over: by what the guest is told, and by the memory of the process while it asks.

Behaviour that diverges inside the interfaces this runtime carries

Section titled “Behaviour that diverges inside the interfaces this runtime carries”
Requirement Deviation Reason Impact
Fetch §2.2.4: a body is a stream, so it hands out the chunks the wire delivered as they arrive. A response body is a single chunk with the whole answer in it, and a request body whose source is a stream is read to its end before the exchange begins. The host performs the whole exchange and hands the answer back at once, so no partial body exists inside the isolate to hand out. Reading a response body incrementally works: the first read() returns the whole answer and the second returns done, so a progress bar driven by it jumps from nothing to done. A request body that never ends never reaches the host.
Fetch §3.2: a request gets an Origin header derived from its client’s origin, on every CORS fetch and on every request whose method is neither GET nor HEAD. No Origin header is added. The isolate has no origin. ECMA-429 §5 names this divergence itself, as the example a server runtime cannot avoid. A server that authorizes on Origin sees none. The host performs the exchange and may add one of its own.
Fetch §2.2.6: a response has a type, one of basic, cors, default, error, opaque or opaqueredirect, and a URL list whose last entry is where the request ended up. Fetch §5.5 reads redirected off the length of that list. type reads default on every response the host returns, redirected is always false, and url is the URL the request named. The host follows the redirect chain, so the isolate sees one answer and never the chain that produced it. Code that branches on type or on redirected takes the same branch every time, and code that reads url to learn where a request ended up reads where it was aimed.
Fetch §2.2.5: a request’s redirect mode decides whether a redirect is followed, and error makes the fetch reject. redirect is validated, stored and readable from the getter, and the outbound message has no field to send it in. Whoever performs the request decides, and every outbound path this runtime ships follows redirects. The outbound wire record is the method, the URL, the headers and the body. Redirect handling belongs to whoever performs the exchange, and neither the native path nor a host function is told what the guest asked for. redirect: 'error' and redirect: 'manual' do not do what they say. A request to an endpoint that responds 302 is followed, and the guest receives the body of a URL it never named, with redirected false and url still the original. Redirect mode is not a boundary here. A host that needs one enforces it in its own outbound function, the only place a redirect can still be refused.
Fetch §4.1: a request’s integrity metadata is checked against the response body, and a mismatch fails the fetch. integrity is validated, stored and readable from the getter, and no check runs. The check would have to run where the body arrives, on the host, and the metadata does not cross with the request. A subresource integrity string has no effect. A body that does not match the digest is delivered as if it did. Code that needs the check digests the body it received with crypto.subtle.digest and compares against the same value it put in integrity.
Fetch §5.4: RequestInit declares mode, credentials, cache, keepalive, referrer and referrerPolicy. All six are validated, stored and readable from the getter, and none of them reaches the exchange. Each governs a browser’s own connection, its cookie jar, its HTTP cache or the referrer it discloses. The isolate has none of them, and the host owns the connection. Code that sets them observes them on the request object and sees no effect on what is sent. A host that needs any of them applies it in its own outbound function.
Fetch §5.4: RequestInit also declares duplex, priority and window, and the constructor throws a TypeError on a non-null window. The three members are never read out of the init object, so a getter defined on one of them never runs and a non-null window is accepted in silence. duplex describes a streaming body the host would have to buffer anyway, and priority reaches a scheduler the isolate does not have. Neither changes the exchange here. One shape differs visibly: new Request(url, { window: 1 }) is built here and rejected in a browser. Request.duplex still reads half. Fetch has that getter return half whatever the init said, and it puts no getter for the other two on Request at all.
Fetch §5.1: a Headers object drops a forbidden request-header name and a forbidden response-header name behind its guard. Every name that passes the token production is accepted. The forbidden lists guard a browser’s own connection. The host owns the connection here and applies its own policy. new Headers({ Host: 'a.example' }).get('host') reads a.example, and Connection survives the same way. Whatever an outbound function forwards reaches the wire, so sanitizing is the host’s job.
Fetch §5.3: blob() returns a Blob whose type is the parsed MIME type, serialized. The type is the raw Content-Type value, ASCII-lowercased by Blob and emptied when it contains anything unprintable. The runtime has no MIME type parser, and every branch that needs one reads the essence instead. A response typed text/plain; charset="utf-8" gives a Blob whose type still has the double space and the quotes, where a browser gives text/plain;charset=utf-8.
HTML §2.7.3 dispatches on the internal slots a value has and rejects an exotic object it writes no steps for, naming a proxy as the example. A live proxy is cloned as the object its traps describe, and a revoked proxy is rejected. Asking a value what it is means invoking it, and a proxy replies through its traps. The rejection follows from that: reading the class string of a revoked proxy throws, and the clone reports A Proxy could not be cloned. from where the throw happened. structuredClone of a proxy returns the trapped view and runs the traps while it walks, so a getter that lies produces a clone with the lie in it.
HTML §2.7.3 reads the internal slots, which no prototype can move. The clone dispatches on the class string instead. Most exotic objects still take that from a slot, but Map, Set, ArrayBuffer, SharedArrayBuffer and a boxed BigInt take it from Symbol.toStringTag on their prototype. Recognizing a re-pointed exotic object means probing every intrinsic and catching a TypeError for each miss, on every ordinary object cloned, and the clone itself costs far less than that. structuredClone(Object.setPrototypeOf(new Map(), Object.prototype)) returns a plain object where a browser returns a Map. Pointing that same Map at a prototype that lends it a class string of its own, Promise.prototype for one, fails with DataCloneError: [object Promise] could not be cloned. A Date, a RegExp or an Error treated the same way still clones correctly. None of these shapes appears in code that clones data.
HTML §2.7.3 clones a SharedArrayBuffer as a second object over the same data block. A SharedArrayBuffer is cloned as itself. One isolate is one agent cluster with one realm, so there is no second wrapper for a shared buffer to become. structuredClone(sab) === sab reads true here, where a browser gives a distinct object over the same memory. Since the buffer cannot grow in this runtime, no view over it can observe the difference.
Web Cryptography §13: a CryptoKey is an opaque reference to keying material the user agent manages, and no clause bounds how many keys an application may hold or how many operations may run at once. The material stays in the host process, and the isolate keeps only the number that identifies it. An execution may create keys up to KEY_LIMIT in packages/executor/src/sandbox/crypto.ts, and one operation runs at a time. Material inside the isolate would be readable by the code the isolate runs, whether the key was imported as extractable or not. Host work sits outside the memory limit and the timeout of the isolate, so the count of keys and the number of threads an execution occupies are capped where those limits cannot reach. A CryptoKey behaves as the standard describes, exporting a non-extractable key included. A loop that generates keys meets the rejection instead of growing the host, and concurrent operations complete in the order they were asked for. An operation still queued when the execution ends never starts, while one already running finishes, so a derivation that states a huge iteration count is paid for once.
Web Cryptography §18.4 normalizes an algorithm by reading the members its dictionary declares, which Web IDL reads through the prototype chain. Only the members an algorithm object owns cross to the host, and only to a fixed depth, one level deeper than the deepest member any dictionary of the interface declares. Normalization happens on the host, where the algorithm tables already are, so the object’s own data crosses, and not a dictionary this runtime would have to declare a second time. crypto.subtle.digest(Object.create({ name: 'SHA-256' }), data) rejects with a TypeError naming name as required, where a browser reads the inherited member and hashes. Every algorithm written as an object literal is unaffected, and that is how they are written.
High Resolution Time §2.1: performance.now reads a monotonic clock, which system clock adjustments cannot move. The reading comes from Date.now, and it never falls below the highest value already handed out. A bare V8 isolate exposes no monotonic clock, and clamping the reading keeps two chronologically recorded readings from ever differing negatively. A measurement shorter than a millisecond reads 0, which the standard permits, since it lets an implementation coarsen the clock further than the 100 microseconds it names. A backwards adjustment of the system clock stalls the reading instead of rewinding it. Event.timeStamp shares the clock and behaves the same way.
ECMA-262 §27.5.1.9 defines HostPromiseRejectionTracker, and HTML §8.1.6.4 wires it to the unhandledrejection and rejectionhandled events. Neither event ever fires, though both handler attributes exist and PromiseRejectionEvent is published, because ECMA-429 requires them. Nothing in a bare isolate reaches the host hook that would fire them. A listener on either name never runs. A rejection nobody handled is dropped and not reported, so it reaches neither the program’s own handler nor the caller’s logs. A rejection the entry function awaits or returns still reports as the execution’s error, so code that must not lose one keeps its promises on that path or attaches a catch where each is created.

Unicode and encoding tables this runtime does not carry

Section titled “Unicode and encoding tables this runtime does not carry”

Two standards ask for a large Unicode table where a server runtime rarely needs one. Both tables stay out. Each row says what takes their place and what a program gives up for it.

Requirement Deviation Reason Impact
URL §3.3: the domain parser runs Unicode ToASCII with CheckBidi and CheckJoiners set, which maps every code point through the UTS46 table and applies the bidi and joiner rules. A domain that is ASCII takes URL §3.3’s own ASCII path, written in the standard as lowercase-and-return and touching no table. A domain that is not ASCII runs the four steps of UTS46 §4 against Unicode 17.0.0 and meets every criterion of §4.1 but two: the ContextJ rules of RFC 5892 appendix A and the bidi rule of RFC 5893 §2 do not run. UTS46 §6 derives the mapping table from NFKC_Casefold, which the engine already implements, so the runtime ships the difference and not the table. The two skipped checks read the Joining_Type and Bidi_Class properties, and the engine hands neither out: \p{General_Category=Mark} compiles while \p{Bidi_Class=Arabic_Letter} and \p{Joining_Type=Dual_Joining} are both syntax errors. A label that mixes an Arabic letter with a Latin one is encoded instead of refused: new URL('https://ا1-a.example/').hostname reads xn--1-a-nze.example here, where an ICU-backed parser rejects the URL outright. The name that comes out does not exist, so the request fails at DNS and not at the URL parser.
Encoding §4.2: the table names every encoding and the labels each one is known by, and §7.2 has TextDecoder throw a RangeError for a label naming no encoding it can decode. This runtime decodes UTF-8, UTF-16BE, UTF-16LE and windows-1252, under every label the index gives them. Every other label is rejected with that RangeError. The rest of the index is legacy multi-byte mapping tables, and no byte sequence inside this isolate arrives in one. The four this runtime decodes are algorithms and not tables, apart from the short one windows-1252 needs. A response without a usable charset falls back to windows-1252. new TextDecoder('shift_jis') throws RangeError: Failed to construct 'TextDecoder': The encoding label provided ('shift_jis') is invalid. Code that has to read a legacy encoding decodes it itself. A label typo is caught here, not silently treated as UTF-8.

URLPattern compiles every component with the regular expression v flag, which URL Pattern §1.5 requires. Inside a character class that flag makes / a syntax error, so a component pattern spelled ([^/]+) raises and ([^\/]+) is the spelling that works. Chrome does the same. Code ported from urlpattern-polyfill, which compiled with u and accepted both, has to change that one spelling and no more.

queueMicrotask is built on the promise job queue, because awaiting an already resolved promise is the only microtask queue a bare isolate offers. That queue is the same one HTML §8.8 enqueues onto, and the await resumes in the job the callback would have occupied, so the callback runs where a browser runs it. Interleaving queueMicrotask with then callbacks produces the same order here as it does under a native implementation, measured against Node.

performance has now, timeOrigin and toJSON and no other member. Those three are exactly the Performance interface High Resolution Time §7 defines, and exactly what ECMA-429 requires. performance.mark, performance.measure and performance.getEntries belong to the Performance Timeline. ECMA-429 names that only as an extension a runtime may add, so performance.mark reads undefined and a library that records its own timings fails at the call without anything being out of conformance.

navigator has one member, userAgent, and it reads Supolka-Executor. ECMA-429 §5.2 requires that member and asks for no other on the object, and §7 recommends a single product token with no version. So navigator.language and navigator.hardwareConcurrency read undefined by the standard’s own design and not against it.

Serializing application/x-www-form-urlencoded takes an encoding in the URL Standard, and this runtime always uses UTF-8, the default the standard itself states. HTML form submission is the only caller that passes anything else, and this runtime does not have it. URLSearchParams is UTF-8 in every direction on every conforming runtime.

ECMA-429 §6 permits a conforming runtime to omit the readonly attribute for the properties it adds to the global scope, so every global this runtime publishes can be overwritten and deleted on purpose.

DOM §2.9 walks an event path, and with no node tree that path contains the target alone. Both passes therefore run at the target, eventPhase reads AT_TARGET throughout, and composedPath returns the target alone. For a lone EventTarget the algorithm produces exactly that, and none of it is a simplification.