Run this yourself. Every number in this article came from
array-grouping-benchmark/in the Coding Dunia code-examples repo.npm install && npm teston Node 22 or newer prints the same tables for your machine.
Object.groupBy() is a built-in JavaScript static method that takes an iterable and a key-selecting callback and returns the items bucketed by that key, replacing the hand-written reduce() accumulator that nearly every codebase carries. Map.groupBy() does the same job and returns a Map. Everybody writes that they're cleaner. Almost nobody says whether they're faster, and almost nobody says what they do differently when the input gets awkward. So I measured both.
The short version: they aren't faster than the code they replace. They are safer, they hold a little less memory, and they differ from reduce() in five ways that the usual comparison table skips.
What Does the Basic Pattern Look Like?
const products = [
{ name: 'Widget', category: 'hardware', price: 12 },
{ name: 'Gadget', category: 'hardware', price: 24 },
{ name: 'Plan', category: 'software', price: 99 },
];
// The pattern in every codebase
const grouped = products.reduce((acc, product) => {
const key = product.category;
if (!acc[key]) {acc[key] = [];}
acc[key].push(product);
return acc;
}, {});
// The built-in
const same = Object.groupBy(products, product => product.category);
The callback gets the element and its index, the same shape as map and filter, so an existing key function usually drops straight in. Map.groupBy(products, product => product.category) is the same call returning a Map. That's the whole API. The interesting part is everything around it.
How Did I Measure Object.groupBy Against reduce()?
I ran eight implementations on identical data, on Node 22.23.3 (V8 12.4.254.21) on an Apple M1 with 8 cores. Those cores were shared with other jobs for the whole session, so the load average sat between 15 and 20 when each run started. That's noise I couldn't remove, so I compensated: the implementations run interleaved in rounds (A, B, C, then B, C, A, and so on), each size and key count runs in a fresh Node process, and every figure is the median of 9 to 15 rounds. I did two complete runs on 2026-10-16 and compared them. The ratios held; the absolute milliseconds moved by up to 30%.
The eight contestants: Object.groupBy, Map.groupBy, reduce into {} with an if, reduce into Object.create(null) with ??=, reduce into a Map, a plain for loop into a null-prototype object, a for loop into a Map, and lodash 4.18.1 _.groupBy. Each had to produce the same grouping before it was allowed a time, so no row got fast by doing less.
Is Object.groupBy Faster Than reduce()?
No. Here are the medians in milliseconds per call, run 1 and run 2:
| Implementation | 1M items, 10 groups | 100k items, 10,000 groups |
|---|---|---|
for loop into a null-prototype object | 15.6 / 20.4 | 2.20 / 2.61 |
reduce into Object.create(null) | 18.5 / 24.8 | 2.72 / 3.06 |
lodash _.groupBy | 19.2 / 23.8 | 2.68 / 3.34 |
for loop into a Map | 17.9 / 22.9 | 3.61 / 4.16 |
reduce into {} with an if | 25.0 / 27.5 | 3.28 / 3.70 |
Map.groupBy | 25.2 / 28.9 | 3.53 / 4.04 |
Object.groupBy | 25.6 / 28.5 | 4.33 / 5.15 |
Read it from the top. A plain loop wins everywhere. Object.groupBy finished between 1.4x and 2.6x behind it in every one of the 16 cells across both runs. Against the classic reduce into {} it was level when there were ten groups, and 1.3x to 1.5x slower when there were a thousand or more. Map.groupBy ran between level with and 1.4x slower than a hand-written Map loop, and was level once the groups got numerous.
I expected the opposite. A built-in sounds like a guaranteed win, and it isn't. My guess is that the cost sits in calling the callback and writing into the result, where the engine has already optimized the plain loop hard, but I didn't profile V8 to prove it. Is a 1.5x gap going to matter in your code? At a million items it's about 10 ms. At a few hundred rows, which is what most grouping calls see, it's microseconds. Pick on correctness.
Object.groupBy and Map.groupBy aren't a speedup: on Node 22 they ran 1.4x to 2.6x slower than a plain loop. What they buy is a result that can't crash on a key called constructor.
One side note for the lodash fans: _.groupBy was 1.2x to 1.3x faster than Object.groupBy at a million items. That's a measurement, not an argument for the dependency. It returns an ordinary object with a prototype, and you'd be paying bytes for a gap you won't see.
Does the Native Version Use Less Memory?
Slightly, and this one is deterministic. I measured heapUsed after two forced garbage collections with the result still alive, minus the same figure before the call, in a separate child process per implementation. Both runs gave identical numbers.
| Implementation | 1M items, 10 groups | 1M items, 100,000 groups |
|---|---|---|
Object.groupBy | 7.63 MB | 18.21 MB |
Map.groupBy | 7.63 MB | 15.71 MB |
reduce into {} | 8.74 MB | 17.37 MB |
hand-written Map loop | 9.03 MB | 22.58 MB |
The native methods hold 13% to 15% less than reduce and the hand-written loops with few big groups, and Map.groupBy holds 30% less than the Map you'd build yourself when there are 100,000 small groups. Object.groupBy is not smaller in that second case; it sits 5% above reduce into {}. My guess at the cause is array capacity: an array grown with push() keeps spare room, and the built-in can size each group exactly. I didn't confirm that in V8, so treat it as a guess.
Which Bug Does the Classic reduce() Pattern Hide?
This is the part I'd lead with if speed were the story. Here's the six-line accumulator from the top of this page, fed a group key that came from data:
const tags = ['css', 'constructor', 'js'];
tags.reduce((acc, tag) => {
if (!acc[tag]) {acc[tag] = [];}
acc[tag].push(tag);
return acc;
}, {});
// TypeError: acc[tag].push is not a function
acc is a plain object, so acc['constructor'] is already the Object function. The existence check passes, there's no array to push to, and the call throws. The same thing happens with toString and __proto__. All three are asserted in semantics.mjs and they pass on Node 22.23.3. Object.groupBy(tags, tag => tag) returns a group called constructor without complaint, because it builds a null-prototype object and treats every key as data.
When does that bite? Whenever the group key comes from outside your code: tags, usernames, CSV headers, query parameters. Test data rarely includes a tag called constructor. Production data eventually does. You can fix the hand-written version by starting from Object.create(null) or switching to a Map, and the benchmark shows that version is also faster, but then you've reinvented what the built-in already does.
What Else Does groupBy Do Differently?
Five behaviors that I asserted rather than assumed:
- Integer-like keys get sorted.
Object.groupBy(['b', '10', 'a', '2', '1'], x => x)returns keys in the order1, 2, 10, b, a. Object keys that look like array indexes come first, ascending, no matter when they appeared.Map.groupByreturnsb, 10, a, 2, 1, first-seen order. An earlier version of this article said a plain object preserves insertion order too. For non-numeric strings it does. For numeric-looking ones it doesn't, and I was wrong to gloss over it. - Keys are coerced.
1and'1'end up in the same group on the object version and in two groups on theMapversion. - Object keys collapse. Group by an object reference with
Object.groupByand every item lands under one key called[object Object].Map.groupBykeeps one group per reference. - Holes are visited, and any iterable works.
[1, , 3]throughObject.groupBy(sparse, x => String(x))yields a group calledundefined, wherereduceskips the hole silently. It also accepts aSet, a string or a generator, none of which havereduce. - The result has no prototype.
result.hasOwnPropertyisundefined, andString(result)throws aTypeError, so interpolating it into a template string blows up where an ordinary object would print[object Object].
Two points in the other direction. Items stay in source order inside each group, and the callback receives (item, index) counted from 0. Map.groupBy also follows SameValueZero, so NaN is a single group and 0 and -0 merge.
When Is Map.groupBy the Better Choice?
const orders = [
{ customerId: 101, total: 40 },
{ customerId: 102, total: 15 },
{ customerId: 101, total: 22 },
];
const byCustomer = Map.groupBy(orders, order => order.customerId);
console.log([...byCustomer.keys()]); // [101, 102], still numbers
console.log(byCustomer.get(101)); // the two orders for customer 101
Use Map.groupBy when the key isn't a string, when you need first-seen order, or when the key might be an object. Use Object.groupBy when the keys are strings you'll read back with Object.entries() and you want JSON-friendly output. If you can't say which, take the Map: it has no key-order surprises, it ran no slower, and in the many-groups case it held less memory.
How Far Can You Trust These Numbers?
Not far in absolute terms. One engine (V8 12.4, Node 22.23.3), one chip, a machine that was busy with other work. Chrome, Firefox and Safari implement these methods separately, and I haven't measured them. A first version of my harness also taught me something: it validated each result with a helper that built a plain object from the same 1,000 keys, and in that process reduce into {} ran 7x to 12x slower than everything else. Run alone, it was fine. Whatever V8 remembers about objects built from the same keys, a benchmark that checks the answer first can change the thing it times. The folder's README documents it, and the validation now runs after the timing.
The direction is what I'd trust: loops beat built-ins, built-ins sit with reduce, and the safety and memory differences are exact. Rerun it before you rewrite anything on the strength of my milliseconds.
What Should You Take Away?
Use Object.groupBy and Map.groupBy because they can't throw on a key called constructor and because every team stops writing its own accumulator. Don't use them to go faster, and don't pretend they do. In a loop that runs a million rows per request, keep the for loop and start from Object.create(null).