The Web-Compatibility Constraint: Hyrum's Law as Normative Force
Every observable behaviour of a deployed engine is an interface somebody depends on. The specification is shaped by that fact more than by any design principle.
JavaScript cannot remove or change anything that existing pages observe, because the pages cannot be recompiled. This single constraint explains Annex B, the names `includes` and `flat`, the existence of `Symbol.unscopables`, why `typeof null` is an eternal bug, why `Array.prototype.contains` failed, and why proposals are now designed with web-compat telemetry before they are designed with taste. Understanding it is the difference between finding the language incoherent and reading it as a fossil record.
In this chapter
Code that cannot be recompiled
Every other mainstream language can make a breaking change with a version flag: Python 3, Rust editions, C++ standards. JavaScript cannot, because its programs are delivered as source to a runtime the author does not control, on pages the author may no longer maintain, and a browser that breaks a page is blamed by its user, not the page's author. The consequence is a one-directional ratchet: features can be added if the additions do not collide with anything deployed, and nothing observable can be removed or changed. TC39's operating rule is stated in every proposal template as "Does this break the web?", and the answer is settled empirically, with browser vendors instrumenting shipping releases to count how many page loads exercise a behaviour before it is allowed to change. Hyrum's law is normally offered as a warning to library authors; for ECMAScript it is the governing law of the specification's evolution.
Annex B: the fossil record
Annex B, "Additional ECMAScript Features for Web Browsers", is where the specification writes down behaviours it would never design but must describe because every browser implements them and pages depend on them. It was informative until ES2015 and is now normative for browser hosts: a browser that omits an Annex B feature is non-conforming. Its contents are a catalogue of dependencies discovered the hard way:
| Annex B feature | Why it exists | What it costs the language |
|---|---|---|
__proto__ accessor on Object.prototype | Shipped by SpiderMonkey in 1996 as a debugging aid; used by libraries to set prototypes before Object.setPrototypeOf existed. | Prototype pollution (chapter 4); an object key that is not an ordinary key in JSON-derived data. |
escape / unescape | Pre-encodeURIComponent URL encoding with the wrong character set. | Two global functions that are wrong for their purpose and cannot be removed. |
HTML-like comments <!-- and --> | Pages wrapped inline scripts in HTML comments so 1995 browsers without <script> support would not render the code as text. | A lexical grammar with a second comment syntax that only applies in scripts, not modules. |
RegExp.prototype.compile, legacy static properties RegExp.$1 | Netscape 3 APIs used by early libraries. | Mutable global state on a constructor; a method that mutates a regex in place. |
| Block-level function declarations in sloppy mode | IE and other engines hoisted functions out of blocks differently; code depended on each engine's behaviour. | Web-legacy semantics specified as a deliberately confusing dual binding, so both classes of code keep working. |
String.prototype.substr, anchor, big, blink, fontcolor... | Netscape's HTML-generating string methods. | Dead API surface that every engine must ship forever. |
Date.prototype.getYear / setYear, toGMTString | Two-digit-year methods from 1995. | A Y2K bug frozen into the standard. |
Octal literals 017 and octal escapes \\017 in sloppy mode | C heritage; removed from strict mode only. | Parsing rules that depend on the code's strictness. |
arguments.callee, Function.prototype.caller | Pre-ES3 recursion and stack introspection idioms. | Sloppy-mode only; poisoned (throwing) in strict mode because they defeat optimisation. |
// Each of these is specified in Annex B and would not survive a design review.
console.log("__proto__ is an accessor on Object.prototype:", typeof Object.getOwnPropertyDescriptor(Object.prototype, "__proto__").get);
console.log("escape() mangles non-Latin-1 with %uXXXX:", escape("é ✓"), "| encodeURIComponent:", encodeURIComponent("é ✓"));
// HTML-like comments: only in Script goal, never in Module goal. Evaluated indirectly so this file stays valid in both.
console.log("HTML-like comment is a comment in scripts:", (0, eval)("1 <!-- this is a comment") === 1, "| in modules <!-- is a syntax error");
console.log("sloppy octal:", (0, eval)("017"), "| strict rejects it:", (() => { try { (0, eval)("'use strict'; 017"); return "accepted"; } catch (e) { return e.constructor.name; } })());
const re = /(\d+)-(\d+)/; re.test("12-34");
console.log("legacy RegExp statics:", RegExp.$1, RegExp.$2, "| RegExp.lastMatch:", RegExp.lastMatch);
console.log("String.prototype.blink still ships:", "x".blink(), "| substr:", "abcdef".substr(-3, 2));
console.log("getYear is years since 1900:", new Date(2026, 0, 1).getYear());
// Block-level functions in sloppy mode: hoisted to the function scope AND block scoped (the "web legacy" dual binding).
console.log("block function visible outside its block (sloppy):", (0, eval)("(function(){ { function f() { return 1; } } return typeof f; })()"));Names are chosen by what the web already uses
Adding a method to a built-in prototype collides with every page that already assigned that name. Three episodes define the modern process. `Array.prototype.contains` (2014): shipped in Firefox Nightly and broke sites using MooTools. MooTools' implement deliberately skips installing a method when a native one already exists, so the new native contains, with different semantics, silently replaced the library's own, and MooTools' internals that relied on it broke. TC39 renamed it includes; String.prototype.contains was renamed to match. `Array.prototype.flatten` (2018): the same MooTools code path broke again; the committee considered smoosh as a joke that briefly became a serious candidate ("SmooshGate") before settling on flat and flatMap. `Array.prototype.values`, `keys`, `entries` (2013): these could not be renamed, since they matched Map and Set; instead Symbol.unscopables was invented so that with (arr) statements in legacy code (ExtJS, among others) would not have the new methods shadow free variables named values. The specification acquired a well-known symbol and a step in Object Environment Record's HasBinding (chapter 1) for the sake of a statement that strict mode had already deprecated.
// A pre-existing library extension of Array.prototype, as MooTools did in 2007.
// (Done on a subclass here so the sandbox's own arrays are untouched.)
class Legacy extends Array {}
Legacy.prototype.contains = function (item, from) { // MooTools: contains(item, from) with a start index
return this.indexOf(item, from) !== -1;
};
const arr = Legacy.from([1, 2, 3]);
console.log("library method kept its own semantics:", arr.contains(1, 0) === true && arr.contains(1, 1) === false);
// Had ES2016 shipped Array.prototype.contains, this feature test would have misfired and the library
// would have skipped installing its version, changing behaviour for every page using it:
const wouldSkipInstall = "contains" in Array.prototype;
console.log("feature test 'contains' in Array.prototype:", wouldSkipInstall, "-> the standard method is includes:", typeof [].includes);
console.log("same story for flatten -> flat:", "flatten" in Array.prototype, "|", typeof [].flat);
// unscopables was the alternative to renaming: hide the new methods from `with` only.
console.log("Array.prototype[@@unscopables] hides:", Object.keys(Array.prototype[Symbol.unscopables]).slice(0, 6).join(", "), "...");Bugs that became specification
- `typeof null === "object"`: a type-tag accident in the 1995 implementation (null was represented as the null pointer, whose tag bits matched objects). A fix was proposed for ES5.1 (
typeof null === "null") and abandoned after finding sites that branched on the existing value. - `Date` month indexing from 0 and `getYear`: copied from
java.util.Datein ten days.Temporal(chapter 19) exists partly becauseDatecannot be repaired in place. - `parseInt("08")` being 0 before ES5: octal inference from a leading zero. ES5 removed the inference for
parseIntbut not for literals, soparseInt("08")is 8 while08is a sloppy-mode syntax error and010is 8. - `Array.prototype.sort` stability: unspecified until ES2019 because V8 used an unstable quicksort for arrays over ten elements; when V8 switched to TimSort, the specification could finally require stability, since no engine any longer depended on instability.
- Function-declaration hoisting in blocks: the Annex B dual binding is not anyone's design; it is the intersection of what IE, Firefox, and Chrome each did, specified so that code written against any of them keeps working.
- `Object.prototype.toString` tags:
[object Arguments],[object Null], and[object Undefined]are preserved because duck-typing libraries (Object.prototype.toString.call(x) === "[object Array]") were the pre-ES5 way to detect arrays across frames.
How compatibility is now engineered rather than discovered
The lesson of contains and flatten was procedural. Proposals that add a name to a widely used prototype now ship first behind a flag, then to a Canary or Nightly channel with a use counter, and the counter's readings are reported to the committee before Stage 3 advancement is confirmed. Removals follow the same path in reverse: a feature is deprecated with a console warning, its use is counted, and only when the counter falls below a threshold that browsers judge acceptable (fractions of a percent of page loads; Chromium documents its thresholds) is removal attempted, and it is reverted if breakage reports arrive. The HTTP Archive corpus (millions of crawled pages) is queried for syntactic patterns before a grammar change. Array.prototype.group was renamed Object.groupBy and moved to a static method in 2023 after a use counter found group on prototypes of shipping libraries (Sugar.js), and Array.prototype.at survived only because the collision with a jQuery-adjacent plugin turned out to be benign. The web is a distributed test suite that runs once, in production, on release day, and the specification process has reorganised itself around that fact.
// A proposal author's first question. Every own property name on these prototypes is
// taken forever; any library that added the same name is at risk when a standard one lands.
const surfaces = { "Array.prototype": Array.prototype, "String.prototype": String.prototype, "Object.prototype": Object.prototype, "Promise.prototype": Promise.prototype, "Iterator.prototype": typeof Iterator === "function" ? Iterator.prototype : Object.getPrototypeOf(Object.getPrototypeOf([][Symbol.iterator]())) };
for (const [name, proto] of Object.entries(surfaces)) {
const own = Reflect.ownKeys(proto).filter((k) => typeof k === "string");
console.log(name.padEnd(19), String(own.length).padStart(3), "string keys, e.g.", own.slice(-4).join(", "));
}
// Recently landed names show the ratchet turning: each of these was once a free name.
const recent = { "Array.prototype.at": [].at, "Array.prototype.findLast": [].findLast, "Array.prototype.toSorted": [].toSorted, "Object.groupBy": Object.groupBy, "Promise.withResolvers": Promise.withResolvers, "Set.prototype.union": Set.prototype.union, "Iterator.prototype.map": surfaces["Iterator.prototype"].map, "Array.fromAsync": Array.fromAsync, "Math.sumPrecise": Math.sumPrecise, "Error.isError": Error.isError };
console.log(Object.entries(recent).map(([k, v]) => k + (typeof v === "function" ? " ✓" : " ✗")).join("\n"));Iterator helpers, Set methods, Promise.withResolvers, and Object.groupBy are ES2024–ES2025; Math.sumPrecise and Error.isError are ES2025/2026-era additions, so a ✗ marks an engine that has not caught up rather than a defect.
Where these ideas come from
The mechanisms in this chapter, traced to the paper, standard, or system that introduced them, and how each reached JavaScript.
| Mechanism | Origin | Path into JavaScript |
|---|---|---|
| Implicit interfaces (Hyrum's law) | Hyrum Wright (named by Titus Winters), Software Engineering at Google, chapter 1 (2020) | With a sufficient number of users of an API, all observable behaviours will be depended on. The web is the largest population of API users in history, and the spec's Annex B is the record of what was depended on. |
| Backward compatibility as a design constraint | Brendan Eich; Allen Wirfs-Brock, "Don't break the web" (Eich); JavaScript: The First 20 Years (Wirfs-Brock & Eich, 2020) (2020) | The ES4 collapse (2008) established that incompatible change is off the table; every proposal since is judged first by whether it could break deployed code. |
| Feature testing and progressive enhancement | Peter-Paul Koch and the browser-compat community, Object detection over browser sniffing (2003) | Testing for a method's existence is the reason adding any method to a prototype is a compatibility event: the test may already have a meaning on some site. |
| Deprecation with usage counters | Chromium's UseCounter / Blink intent process, Chromium's Intent to Deprecate and Remove process (2014) | Removals and renames in TC39 are now argued with page-load percentages from browser telemetry, not from principle. |
Primary sources
- ECMA-262, Annex B: Additional ECMAScript Features for Web Browsers
- ECMA-262, Annex B.3.2 Block-Level Function Declarations Web Legacy Compatibility Semantics
- Wirfs-Brock, A. & Eich, B. (2020). JavaScript: The First 20 Years
- Hyrum Wright: Hyrum's Law
- TC39: Array.prototype.includes, history of the rename from contains
- Mathias Bynens: #SmooshGate FAQ
- TC39 proposal: Array grouping (renamed to Object.groupBy after web-compat findings)
- Chromium: Blink principles of web compatibility and deprecation thresholds
- HTML Living Standard: the [[IsHTMLDDA]] internal slot (document.all)