ES2024 JavaScript Features: Object.groupBy, Promise.withResolvers, Resizable ArrayBuffer and the RegExp v Flag
Key takeaways
ES2024 (ECMAScript 15th edition) added six features: array grouping, Promise.withResolvers, resizable and transferable ArrayBuffers, String isWellFormed/toWellFormed, Atomics.waitAsync and the RegExp v flag. This article shows each with real Node.js output, the pitfalls, and which popular features are often misattributed to ES2024.
ES2024 is the 15th edition of the ECMAScript specification, published by Ecma in June 2024. It is a small release: six features, several of them aimed at low-level code (binary buffers, shared memory, Unicode correctness) rather than everyday application logic. Articles about “ES2024 features” frequently pad the list with proposals that were not in it, so the first section below sorts out what is and is not ES2024. After that, each real feature gets an explanation of the problem it solves, runnable code, and the pitfalls.
All outputs in this article were produced with Node.js 24.
What ES2024 contains (and what it does not)
| Feature | What it gives you | First Node.js major |
|---|---|---|
Object.groupBy, Map.groupBy | Native grouping of any iterable | 21 |
Promise.withResolvers() | A promise plus its resolve/reject functions | 22 |
Resizable ArrayBuffer, growable SharedArrayBuffer | Buffers that can change size in place | 20 |
ArrayBuffer.prototype.transfer() | Move ownership of a buffer, detaching the original | 21 |
String.prototype.isWellFormed() / toWellFormed() | Detect and repair lone UTF-16 surrogates | 20 |
Atomics.waitAsync() | Non-blocking wait on shared memory | 16 |
RegExp v flag (unicodeSets) | Set operations and string properties in character classes | 20 |
Features often attributed to ES2024 that are not in it:
Array.prototype.toSorted,toReversed,toSpliced,with,findLast,findLastIndexare ES2023.Array.fromAsyncwas still a Stage 3 proposal when ES2024 was finalized and was standardized in a later edition. It already ships in current engines, including Node 24, so you can use it, but it is not “an ES2024 feature”. Note that it awaits items one at a time: mapping three 100 ms requests throughArray.fromAsynctook about 300 ms in Node 24, versus about 100 ms withPromise.all. It is for consuming async iterables, not for running requests concurrently.- Decorators are a Stage 3 proposal, not part of any published edition. Node.js 24 rejects
@decsyntax withSyntaxError: Invalid or unexpected token; you need TypeScript 5.0+ or Babel to use them. See TypeScript decorators for the compiled approach. - Temporal is a separate Stage 3 proposal.
typeof Temporalis"undefined"in Node.js 24. - Iterator helpers,
Setmethods,Promise.try,RegExp.escapeand import attributes are ES2025.
This matters in practice when you configure tooling: setting a TypeScript target or a Babel preset to ES2024 will not polyfill or transform decorators or Array.fromAsync, because the tooling follows the spec editions.
Object.groupBy and Map.groupBy
Grouping items by a key is something almost every codebase does, and before ES2024 it meant a hand-written reduce or a utility library such as Lodash’s groupBy. The native version takes any iterable and a callback that returns the group key:
const products = [
{ name: "Laptop", category: "electronics", price: 999 },
{ name: "Shirt", category: "clothing", price: 29 },
{ name: "Phone", category: "electronics", price: 699 },
{ name: "Jeans", category: "clothing", price: 79 },
];
const byCategory = Object.groupBy(products, (p) => p.category);
console.log(Object.keys(byCategory)); // [ 'electronics', 'clothing' ]
console.log(byCategory.clothing.map((p) => p.name)); // [ 'Shirt', 'Jeans' ]
const byTier = Object.groupBy(products, (p) => (p.price < 100 ? "budget" : "premium"));
// [Object: null prototype] { premium: [Laptop, Phone], budget: [Shirt, Jeans] }
The callback also receives the index, and the input can be any iterable, including generators:
function* nums() { yield 1; yield 2; yield 3; }
Object.groupBy(nums(), (n, i) => `${n > 1 ? "big" : "small"}-${i}`);
// { 'small-0': [ 1 ], 'big-1': [ 2 ], 'big-2': [ 3 ] }
Note that it is a static method on Object, not Array.prototype.group. The earlier proposal used prototype methods, but they conflicted with existing websites that had added their own group method to arrays, so the committee moved grouping to static functions.
Pitfall 1: the result has a null prototype
console.log(Object.getPrototypeOf(byCategory)); // null
byCategory.hasOwnProperty("clothing");
// TypeError: byCategory.hasOwnProperty is not a function
Object.hasOwn(byCategory, "clothing"); // true
"toString" in byCategory; // false
This is deliberate: a product whose category is "constructor" or "__proto__" cannot collide with an inherited property. But code that calls result.hasOwnProperty(...) (common in older code and in some serializers) throws. Use Object.hasOwn.
Pitfall 2: keys become strings
Object.groupBy turns keys into property keys, which has two consequences. Integer-like keys are reordered (JavaScript objects always list integer keys in ascending order first), and object keys all collapse into one:
Object.groupBy([1, 2, 3], (n) => n % 2);
// { '0': [ 2 ], '1': [ 1, 3 ] } <- group '1' was created first, but '0' is listed first
const alice = { id: 1 }, bob = { id: 2 };
const orders = [{ user: alice, total: 10 }, { user: bob, total: 5 }, { user: alice, total: 7 }];
Object.keys(Object.groupBy(orders, (o) => o.user));
// [ '[object Object]' ] <- alice and bob merged into one group
Map.groupBy keeps keys as they are, comparing objects by identity:
const byUser = Map.groupBy(orders, (o) => o.user);
console.log(byUser.size, byUser.get(alice).length); // 2 2
This merge is the failure I watch for when reviewing grouping code that keys by an object or a Date: the page renders a single group labeled [object Object] (or one long date string) and nothing errors. My rule is that Object.groupBy is for string-like keys (categories, statuses, formatted dates) and anything else goes through Map.groupBy.
TypeScript note
TypeScript declares these methods in lib: ["ES2024"] (older 5.x versions had them under ESNext). Object.groupBy is typed as returning Partial<Record<K, T[]>>, so byCategory.clothing is Product[] | undefined and must be checked before use. That is accurate: a group only exists if at least one item produced its key.
Promise.withResolvers
The Promise constructor gives you resolve and reject only inside the executor callback. When the code that settles the promise lives elsewhere (an event handler, a queue, a callback API), people have long written the “deferred” pattern by hand:
let resolve, reject;
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
Promise.withResolvers() returns all three at once:
const { promise, resolve, reject } = Promise.withResolvers();
setTimeout(() => resolve("done"), 10);
console.log(await promise); // done
A realistic use is waiting for an event with a timeout, where the resolve and reject paths live in different callbacks and both must clean up:
import { EventEmitter } from "node:events";
function waitFor(emitter, event, ms) {
const { promise, resolve, reject } = Promise.withResolvers();
const timer = setTimeout(() => {
emitter.off(event, onEvent);
reject(new Error(`timeout waiting for ${event}`));
}, ms);
function onEvent(value) {
clearTimeout(timer);
resolve(value);
}
emitter.once(event, onEvent);
return promise;
}
const em = new EventEmitter();
setTimeout(() => em.emit("ready", 42), 5);
console.log(await waitFor(em, "ready", 100)); // 42
await waitFor(em, "never", 20); // rejects: timeout waiting for never
The semantics are identical to new Promise: only the first call to resolve or reject has any effect, and later calls are silently ignored.
const r = Promise.withResolvers();
r.resolve("first");
r.resolve("second");
r.reject(new Error("x"));
console.log(await r.promise); // first
The trade-off to be aware of: a promise whose resolvers escape into long-lived structures can simply never settle, and await on it hangs forever with no error. With the executor pattern, the settling logic sits visibly next to the creation; with withResolvers, it can be anywhere. When I use it, I pair every stored resolver with a timeout or an abort path, like the waitFor helper above. The async programming article covers more of these promise-lifetime bugs.
Resizable ArrayBuffer and transfer
Before ES2024, an ArrayBuffer had a fixed size. Growing a binary buffer (for a parser reading a stream, for example) meant allocating a larger buffer, copying, and recreating every typed-array view. Now you can opt into resizing by passing maxByteLength:
const buf = new ArrayBuffer(8, { maxByteLength: 64 });
const view = new Uint8Array(buf); // length-tracking view
console.log(buf.resizable, buf.byteLength, view.length); // true 8 8
buf.resize(32);
console.log(buf.byteLength, view.length); // 32 32 <- the view followed
A typed array created without an explicit length tracks the buffer’s size. A view with a fixed offset and length does not, and if the buffer shrinks below it, the view goes out of bounds and reports length 0 instead of throwing:
const fixedView = new Uint8Array(buf, 0, 4);
buf.resize(2);
console.log(view.length, fixedView.length); // 2 0
Resizing beyond maxByteLength, or calling resize on a normal buffer, throws:
RangeError: ArrayBuffer.prototype.resize: Invalid length parameter
TypeError: Method ArrayBuffer.prototype.resize called on incompatible receiver #<ArrayBuffer>
Declaring the maximum at creation lets the engine reserve address space up front so that growth does not have to move data. SharedArrayBuffer gets the equivalent maxByteLength option, a growable property and a grow() method; shared buffers can only grow, never shrink, because other threads may hold views into them.
transfer and detachment
buffer.transfer(newLength?) moves the contents into a new ArrayBuffer and detaches the original, similar to what postMessage does when you list a buffer as transferable:
const src = new ArrayBuffer(4);
new Uint8Array(src).set([1, 2, 3, 4]);
const dst = src.transfer();
console.log(src.detached, src.byteLength, new Uint8Array(dst));
// true 0 Uint8Array(4) [ 1, 2, 3, 4 ]
new Uint8Array(src);
// TypeError: Cannot perform Construct on a detached ArrayBuffer
new Uint8Array(dst.transfer(8)); // [1, 2, 3, 4, 0, 0, 0, 0] (grows, zero-filled)
The point is ownership: after a transfer, code that still holds the old buffer cannot read or mutate data it no longer owns, and the engine can often avoid a copy. transfer() preserves resizability, while transferToFixedLength() always produces a fixed-size buffer. The detached-buffer TypeError is what you see when some other part of the program kept a reference to the old buffer, so if you adopt transfer, audit who else holds views.
String isWellFormed and toWellFormed
JavaScript strings are sequences of UTF-16 code units. Characters outside the Basic Multilingual Plane, including most emoji, take two units (a surrogate pair). Slicing by .length can cut a pair in half and leave a lone surrogate, which is not valid Unicode:
const truncated = "Hi 👋".slice(0, 4);
console.log(truncated.isWellFormed()); // false
console.log(new TextEncoder().encode(truncated)); // [72, 105, 32, 239, 191, 189]
TextEncoder silently replaced the broken half with U+FFFD (bytes 239 191 189), and some APIs are stricter:
const lone = "abc\uD83D";
encodeURIComponent(lone);
// URIError: URI malformed
console.log(lone.isWellFormed()); // false
console.log(encodeURIComponent(lone.toWellFormed())); // abc%EF%BF%BD
isWellFormed() lets you detect the problem (to reject input or log it), and toWellFormed() replaces every lone surrogate with U+FFFD so that the string can be safely encoded. Before ES2024 this needed a regular expression over surrogate ranges. The underlying fix is still to truncate by code points or grapheme clusters (Intl.Segmenter) rather than by UTF-16 units; these methods are the safety net at API boundaries.
Atomics.waitAsync
Atomics.wait blocks the calling thread until a value in shared memory changes, which is why browsers do not allow it on the main thread. Atomics.waitAsync is the non-blocking version: it returns immediately with an object whose value is either a promise or, if no wait is needed, a string result.
const i32 = new Int32Array(new SharedArrayBuffer(4));
const res = Atomics.waitAsync(i32, 0, 0, 1000); // wait while i32[0] === 0
console.log(res.async); // true
setTimeout(() => {
Atomics.store(i32, 0, 1);
console.log("notified", Atomics.notify(i32, 0)); // notified 1
}, 10);
console.log(await res.value); // ok
const res2 = Atomics.waitAsync(i32, 0, 0);
console.log(res2.async, res2.value); // false not-equal
The possible results are "ok" (woken by notify), "timed-out" and "not-equal" (the value already differed, returned synchronously). Always branch on res.async before awaiting.
One Node.js behavior caught me out when testing this: a pending waitAsync timeout does not keep the event loop alive. In a script whose only pending work was await Atomics.waitAsync(i32, 0, 1, 20).value, Node exited with “Warning: Detected unsettled top-level await” instead of resolving to "timed-out". In real programs other handles (servers, workers) usually keep the process running, but in small scripts and tests you need something else pending. This is a low-level primitive for coordinating workers; most application code is better served by higher-level messaging.
The RegExp v flag
The v flag (unicodeSets) is an upgraded u flag. It enables three things inside character classes.
Set subtraction and intersection:
/[\p{L}--[a-z]]/v.test("a"); // false (letters minus ASCII lowercase)
/[\p{L}--[a-z]]/v.test("é"); // true
"Hello, Wörld! 123".match(/[\p{L}&&\p{Lowercase}]/gv).join(""); // "elloörld"
/[\p{Script=Greek}&&\p{Letter}]/v.test("π"); // true
Properties of strings, such as \p{RGI_Emoji}, which match multi-code-point emoji as a unit. With the old u flag, \p{Emoji} only matches single code points:
"👍🏽".match(/^\p{Emoji}$/u); // null (thumbs up + skin tone = 2 code points)
/^\p{RGI_Emoji}$/v.test("👍🏽"); // true
String literals in classes with \q{...}: /[\q{👍🏽|abc}]/v.test("abc") is true.
The pitfall when migrating from u to v is that v is stricter about unescaped syntax characters inside classes, because characters such as (, ), [, {, /, - and | now have meaning there:
/[(]/u.test("("); // true
new RegExp("[(]", "v");
// SyntaxError: Invalid regular expression: /[(]/v: Invalid character in character class
u and v cannot be combined (SyntaxError: Invalid flags supplied to RegExp constructor 'uv'). When I switch an existing pattern to v, I run it through the test suite rather than assuming it is a drop-in change, since the failures only show up when the pattern is compiled.
Runtime support and tooling
For server code, Node.js 22 or later supports everything in ES2024. For browsers, each feature shipped at a different time in each engine, so check MDN’s compatibility table for the specific method rather than treating “ES2024 support” as a single milestone.
Transpilers can only do part of the job. Syntax (the v flag) must be transformed at build time, and Babel has a transform for it. API additions (Object.groupBy, Promise.withResolvers, String.prototype.isWellFormed) can be polyfilled with core-js. Resizable buffers, transfer and Atomics.waitAsync depend on engine internals and cannot be fully polyfilled, so code that needs them should feature-detect:
const canResize = "resize" in ArrayBuffer.prototype;
const hasGroupBy = typeof Object.groupBy === "function";
See the Babel setup guide for configuring presets and polyfills for older targets.