Lesson 27 of 37
Modern JavaScript: What's Stable vs What's a Browser API
Recently-stable TC39 additions that ship everywhere (Object.groupBy, Promise.withResolvers, Error cause, Set methods), vs the browser APIs often mistaken for the language.
JavaScript as a language and JavaScript as a browser platform get blurred together in practice. People talk about "fetch" and "Object" in the same breath. But the two are shipped by different people on different schedules, and a feature that works in Chrome won't necessarily work in a Node script, and vice versa.
This lesson is the clearup. It covers:
- Recent TC39 additions that are stable, universally shipped, and worth reaching for.
- What's a browser API (a.k.a. a "host-provided API") and not part of the language, which is why the same code sometimes works in one environment and not another.
The line between language and platform
The ECMAScript specification — maintained by TC39 — defines the language: syntax, types, prototypes, standard built-ins (Object, Array, Map, Set, Promise, Error, Math, JSON, RegExp, and so on). Every compliant JavaScript engine implements it: V8 (Chrome, Node, Edge), SpiderMonkey (Firefox), JavaScriptCore (Safari, Bun), and smaller ones like QuickJS.
Everything else — fetch, setTimeout, localStorage,
requestAnimationFrame, navigator, document, window, Web
Workers, Service Workers, the Cache API, Push, IndexedDB — is a
host-provided API. The browser implements it; Node implements a
subset for compatibility; Bun and Deno implement overlapping subsets.
These are not "JavaScript features." They're platform features that
happen to be called from JavaScript.
The useful consequence: if you're writing a Node script and
reach for localStorage, it doesn't exist — not because it's
deprecated, but because localStorage is a browser thing and Node
isn't a browser. If you're writing a browser bundle and reach for
require(), same story in reverse.
Recently-stable language features worth knowing
These are the TC39 additions in the last few years that have settled into real codebases. All of them are available in current Node, Chrome, Firefox, and Safari — "safe to use" in any realistic project.
Object.groupBy and Map.groupBy
Turn an array into groups keyed by some property:
const items = [
{ name: "apple", kind: "fruit" },
{ name: "carrot", kind: "vegetable" },
{ name: "pear", kind: "fruit" },
];
Object.groupBy(items, (item) => item.kind);
// { fruit: [{apple}, {pear}], vegetable: [{carrot}] }Replaces the classic reduce((acc, item) => { ... }, {}) grouping
pattern. Map.groupBy is the sibling for when your key isn't a
string.
Promise.withResolvers
A one-liner for "I need a Promise whose resolve and reject I control from outside the executor":
const { promise, resolve, reject } = Promise.withResolvers();
socket.addEventListener("message", (e) => resolve(e.data));
socket.addEventListener("error", (e) => reject(e));
const msg = await promise;Replaces the slightly-awkward let outerResolve; const p = new Promise(r => (outerResolve = r)); pattern.
Error with cause
Chain errors cleanly:
try {
await uploadFile(file);
} catch (ioError) {
throw new Error("Upload failed — please retry", { cause: ioError });
}The outer error has its own useful message; the inner error is
preserved as err.cause for logs and debugging. Modern error
reporters display both.
Array.prototype.toSorted / toReversed / toSpliced
The non-mutating versions of sort, reverse, and splice:
const nums = [3, 1, 2];
const sorted = nums.toSorted(); // [1, 2, 3], original untouched
const reversed = nums.toReversed(); // [2, 1, 3]
const edited = nums.toSpliced(1, 1); // [3, 2]Pays off in React / Redux / immutable-state code where you'd
otherwise [...nums].sort() to avoid mutating.
Set methods
Set finally got the method set it was missing:
const a = new Set([1, 2, 3]);
const b = new Set([2, 3, 4]);
a.intersection(b); // Set { 2, 3 }
a.union(b); // Set { 1, 2, 3, 4 }
a.difference(b); // Set { 1 }
a.symmetricDifference(b); // Set { 1, 4 }
a.isSubsetOf(b); // false
a.isSupersetOf(b); // false
a.isDisjointFrom(b); // falsePreviously these were all manual loops. They aren't anymore.
structuredClone
Deep-clone almost anything, including Dates, Maps, Sets, ArrayBuffers, cyclic references, and typed arrays:
const copy = structuredClone(original);Replaces the JSON.parse(JSON.stringify(x)) trick, which lost Dates,
undefined, functions, and anything with a cycle. structuredClone is
in every modern engine (browsers and Node) and is the right default
for deep-copying structured data.
A note on Temporal
The new date-and-time API (Temporal.PlainDate, Temporal.Duration,
etc.) is on the standards track and designed to replace the ergonomic
mess that is Date. Its adoption status varies by engine and is
worth checking at the time you'd use it. Where it's not yet native, a
polyfill is available and the design is stable — but unlike the items
above, "ships everywhere right now" isn't quite the story yet. Treat
it as the direction for date handling; reach for a library like
date-fns or luxon in the meantime if your runtime hasn't caught
up.
Browser APIs often mistaken for "the language"
These aren't JavaScript. They're the browser. Knowing the difference saves confusion when the same code behaves differently in Node:
fetch,Request,Response,Headers— originally browser, now also in Node 18+ and Bun and Deno. "Mostly everywhere" but not part of ECMAScript.setTimeout,setInterval,queueMicrotask— timers are host-provided.queueMicrotaskis ECMAScript-adjacent (defined in HTML, not TC39).localStorage,sessionStorage,IndexedDB— browser only. Not in Node (without a polyfill).requestAnimationFrame— browser only. Tied to the screen's refresh cycle, which doesn't exist in Node.navigator,location,document,window— the DOM, pure browser.console— host-provided and intentionally not standardised, which is why Node and the browser differ in minor ways like howconsole.logcolourises output.
The useful mental model: language features travel with the engine; platform features travel with the host. A new ES feature lands everywhere JS runs at roughly the same time. A new browser API lands whenever that specific browser ships it, and Node may or may not ever adopt a compatible version.
When this distinction bites you
Two shapes of confusion worth watching for:
- "Why doesn't this work in Node?" You're reaching for
localStorage,document,navigator, orrequestAnimationFramein server-side code. These aren't JavaScript; they're the browser. The server doesn't have them. Reach for the Node equivalent (fsfor persistence, nothing for DOM) or structure the code so that code path doesn't run on the server. - "Why doesn't this work in the browser?" You're reaching for
require(),process.env,fs, orpathin a browser bundle. These aren't JavaScript either; they're Node. Reach forimport,import.meta.env(if your bundler supports it), and HTTP / WebSocket for data instead of files.
Framework and bundler ecosystems hide a lot of this distinction — Next.js and SvelteKit let you write both server and browser code in the same file, and the bundler splits it — but they hide it; they don't abolish it. When something behaves oddly across environments, "am I using the language or the platform?" is the useful first question.
What to remember
- The language (ECMAScript / TC39) defines JavaScript's syntax, built-ins, Object / Array / Set / Map / Promise / Error semantics. Every modern JS engine ships it.
- Browser APIs —
fetch,setTimeout,localStorage,document,navigator,requestAnimationFrame— are host-provided, not part of the language. Node implements a subset; others may or may not. - Safe recent language features:
Object.groupBy/Map.groupBy,Promise.withResolvers,Errorwithcause, non-mutating array methods (toSorted,toReversed,toSpliced), Set methods (intersection,union, etc.), andstructuredClone. Temporalis the stable design for date/time but engine adoption is partial — reach for a library if your runtime hasn't shipped it.- "Why doesn't this work in Node/in the browser?" is almost always a language-vs-platform question. Identifying which category the feature is in is the first step to figuring out what to reach for instead.
Check yourself
4 questions · pass 3/4 to unlock Modules
1.Which of these is a language feature (TC39 / ECMAScript proposal, shipped in every modern JS engine) vs. a browser API (available only in the browser, not the language itself)?
2.What does
Object.groupBy(items, item => item.category)return?3.Why is
Promise.withResolvers()useful compared to the oldernew Promise((resolve, reject) => ...)constructor?4.What's the point of
new Error('upload failed', { cause: ioError }), and what changed between older error handling and this?
4 left to answer
Discussion
Sign in to postNo comments yet. Be the first to say something.