Node.js Performance #2: Object vs Map

An In-depth Comparison: Understanding the Performance and Efficiency of Object vs Map in JavaScript's V8 Engine

Intro

In JavaScript, two major data structures are used to store key-value pairs: Objects and Maps. Both have their unique characteristics, benefits, and use cases, but they serve the same general purpose – to store data in a key-value format.

Objects are the fundamental blocks of JavaScript and have been around since its inception. They offer a straightforward way to create and manipulate named properties, which can be either simple values, other objects, or functions. Objects are ideal for scenarios when you know the keys in advance, as they provide a simple and efficient way to structure data.

On the other hand, Maps are a relatively newer feature introduced in ES6. They maintain the insertion order of elements, making them excellent for scenarios where data order matters. Maps also allow more key types than objects, broadening their usability.

Understanding the differences between these two data structures is crucial for effective JavaScript programming. It can greatly improve your code's performance and make it easier to manage and manipulate data.

So today, we'll talk about Object vs Map.

💡
Since "Object" is a very broad term, and even the Map's type is an "Object", we will focus on using Objects as a key-value storage mechanism.

Brief history

Before ES6 introduced Maps, JavaScript Objects were the main choice for key-value storage. They were versatile, but had limitations. For example, they only accepted strings and symbols as keys, which limited their use. There are no methods for determining the map size for constant time, issues with iteration, and potential name collisions with object’s own properties.

const someMap = { a: 1, b: 2 };

// Object.keys takes O(n) time
const size = Object.keys(someMap).length;

// for .. in will return inherited properties
Object.prototype.someField = "some value";
const someOtherMap = { c: 3 };

for (const key in someOtherMap) {
    console.log(key); // 'c', 'someField'
}

// better to use Object.keys(), but again it requires O(n) time to create array with keys
for (const key of Object.keys(someOtherMap)) {
    console.log(key); // 'c'
}

// it's possible to accidently shadow method from prototype 
const methodsResultMap = { someMethod: true };
methodsResultMap["hasOwnProperty"] = false;

// Uncaught TypeError: methodsResultMap.hasOwnProperty is not a function
methodsResultMap.hasOwnProperty("someMethod");

Despite these drawbacks, Objects were often used for key-mapping storage because there were no other options. They provided a simple way to create and handle named properties. Plus, they've been part of JavaScript since the beginning, so developers were used to them. And so much accustomed to them, that now Map is still way underused.

const someMap = new Map([
    ['a', 1],
    ['b', 2]
]);

But how can we compare them in terms of performance? Is an Object still overall better than a Map, and are there valuable objections to using a Map? Let's find out.

Performance

Before taking any performance benchmarks, we need to answer the question: What should we benchmark?

So, I would choose the five main operations with a map: filling the map with values, checking if a value exists, retrieving a particular element by key, iterating through all elements, and clearing the map. At least, these are my general operations with map-like data structures.

Data preparation

To avoid any correlations between data and Object/Map instances, we need to ensure that we're using the same data for both implementations. For our benchmark scenarios, I'll prepare two different arrays: one for keys and one for values.

For keys, there will be two variants: strings of random lengths and fixed-size hash-like strings:

const keysDefault = ["aa", "aabbb", "aacb", ... ];
const keysHashes = ["c89efdaa54c0f20c7adf612882df0950f5a951637e0307cdcb4c672f298b8bc6", "ad7c5bef027816a800da1736444fb58a807ef4c9603b7848673f7e3a68eb14a5", ...];

For values, I'll also use two sets: random strings and objects with the same structure but with different property values (to simulate TypeScript scenarios like Map<String, SomeType>):

const valuesDefault = ["aa", "aabbb", "aacb", ... ];
const valuesObject = [
    {
        a: "aaa",
        b: "aabbb",
    },
    {
        a: "aacb",
        b: "bbbccc",
    },
    ...
]

As always, feel free to grab the sources and play with your scenarios.

Filling the map with values

As we decide to use arrays for keys and values in the case of an Object, the code will be as follows:

function fillObjectMap(keys, values) {
    const result = {};

    for (let i = 0; i < keys.length; i++) {
        result[keys[i]] = values[i];
    }

    return result;
}

And for Map:

function fillNativeMap(keys, values) {
    const result = new Map();

    for (let i = 0; i < keys.length; i++) {
        result.set(keys[i], values[i]);
    }

    return result;
}

Checking if a value exists or not

For checking values, I'll use an array with 50% of existing keys and 50% of random keys that don't exist.

For Object:

for (let i = 0; i < keysToCheck.length; i++) {
      result[i] = objectMap.hasOwnProperty(keysToCheck[i]);
}

For Map:

for (let i = 0; i < keysToCheck.length; i++) {
      result[i] = nativeMap.has(keysToCheck[i]);
}

Getting a particular element by key

For getting values the same array as for checking will be used.

For Object:

for (let i = 0; i < keysToGet.length; i++) {
      result[i] = objectMap[keysToGet[i]];
}

For Map:

for (let i = 0; i < keysToGet.length; i++) {
      result[i] = nativeMap.get(keysToGet[i]);
}

Iterating through all elements

Usually, in my daily work, I need to iterate through keys and values at the same time, so let's use the same pattern here.

For Object:

for (const [key, value] of Object.entries(objectMap)) {
    result.push([key, value]);
}

For Map:

for (const [key, value] of nativeMap.entries()) {
    result.push([key, value]);
}

Clearing the map

Since Object doesn't have methods to clear all keys, there is no reason to benchmark the clear method on Map. Instead, we'll compare the delete object[key] operator with the delete method.

Object:

for (let i = 0; i < keysToDelete.length; i++) {
      delete objectMap[keysToDelete[i]];
}

Map:

for (let i = 0; i < keysToDelete.length; i++) {
      nativeMap.delete(keysToDelete[i]);
}

Benchmarking

So, let's benchmark our cases to see which one, Object or Map, is faster.

As with the previous time, the approach is as follows:

  • Generate input data;

  • Do some warmup: run a couple of dozen loops to give the JS engine time to compile and optimize the methods;

  • Run the benchmark: again, run a couple of dozen loops with the method calls, but now with time measurement;

  • Check the results.

💡
The benchmark was run on Node.js v20.11.1 on a MacBook Pro with an M1 Pro (10 cores).

The source code for the benchmarking can be found here.

Results

Since we have a relatively large amount of data, to minimize variables such as GC, I'll conduct four iterations for each experiment (2 in the calling order of object → native, 2 – native → object to average GC times). We have a lot of data, so let's go through the numbers.

Filling the map with values

Let's start with the default variant, where the key and value are random strings with A-Za-z characters with a typical length ranging from 3 to 14.

SizeIteration #Object (ms)Map (ms)Diff (%)
10010,8640,16540%
20,8110,162501%
30,7880,179440%
40,8580,188456%
avg0,8300,172482%
1_00017,3150,786931%
27,2810,768948%
35,8591,442406%
45,891,404420%
avg6,5861,100599%
100_0001685,451133,766512%
2696,037134,949516%
3611,193172,48354%
4626,051170,917366%
avg654,683153,028428%
200_00011663,113282,725588%
21704,195283,309602%
31485,016369,127402%
41498,019372,934402%
avg1587,586327,024485%
500_00015080,894921,806551%
25057,104934,713541%
34573,4261236,958370%
44614,1941236,319373%
avg4831,4051082,449446%

Okay, it appears that the order of calls makes sense, albeit not entirely. We can also say that the relationship between size and time is almost linear, so our measurements seem logical because the insert operation cost in a map is O(1).

In general, Map is ~4.5-5 times faster for insertion. Let's examine the changes if we use a string with 256-bit hash as a key.

SizeIteration #Object (ms)Map (ms)Diff (%)
10011,2170,0971255%
21,1970,0931287%
30,7290,356205%
40,6890,343201%
avg0,9580,222431%
1_000110,0670,6731496%
210,0710,6831475%
35,5753,037184%
45,5753,068182%
avg7,8221,865419%
100_00011018,072134,154759%
21013,994132,808764%
3661,416327,258202%
4656,942327,738200%
avg837,606230,490363%
200_00012438,607317,095769%
22359,677305,373773%
31642,642725,101227%
41692,786729,309232%
avg2033,428519,220392%
500_00016770,6921107,551611%
26768,211117,992605%
34764,6692403,295198%
44793,9742387,991201%
avg5774,3861754,207329%

Still, on average, Map is around ~4 times faster, but now keys require much more memory and I suspect that degradation is related to GC work or other memory-related stuff. We'll examine memory consumption later. I won't add tables and charts for the case when values are objects, because there's nothing special there. Same trends. So, let's move on.

Checking if a value exists or not

Since existence check is also O(1) in a map, I don't expect any drastic difference in performance here.

SizeIteration #Object (ms)Map (ms)Diff (%)
10010,1250,094133%
20,1170,092127%
30,1090,11595%
40,1010,11191%
avg0,1130,103110%
1_00011,140,928123%
21,10,867127%
30,9520,923103%
40,9110,876104%
avg1,0260,899114%
100_0001149,636125,14120%
2146,882123,977118%
3148,797124,776119%
4146,461125,339117%
avg147,944124,808119%
200_0001392,979331,654118%
2387,818334,597116%
3357,272348,509103%
4359,373360,486100%
avg374,361343,812109%
500_00011209,2611335,89691%
21197,8111317,79191%
31069,4711375,36278%
41065,3861405,12676%
avg1135,4821358,54484%

From the values, we can see that on average, Map is only 7% faster. However, the larger the size, the smaller the difference, until eventually, for 500k, Object is faster. So, I would say that for checking Map and Object show the same performance in general. Hashed strings and objects-as-values don't change the overall behaviour, so I'll skip them.

Getting a particular element by key

Again getting an element is like existence check also O(1) in a map and we should get the same results.

SizeIteration #Object (ms)Map (ms)Diff (%)
10010,3510,068516%
20,3330,061546%
30,2760,115240%
40,2730,107255%
avg0,3080,088351%
1_00012,5930,564460%
22,8130,586480%
32,0680,938220%
42,0820,946220%
avg2,3890,759315%
100_0001367,05195,139386%
2366,47894,566388%
3320,579135,487237%
4322,573137,071235%
avg344,170115,566298%
200_0001920,408266,668345%
2920,291279,06330%
3790,696391,702202%
4771,922392,069197%
avg850,829332,375256%
500_00012778,0051019,075273%
22792,2321036,136269%
32354,461462,906161%
42338,4361562,371150%
avg2565,7831270,122202%

Surprise, surprise! I thought we would get the same results as for the existence check, but Map outperforms Object here by approximately 2.8 times. If we take a look at Map times, we will notice that the has() and get() methods take almost the same time, while hasOwnProperty() is much faster than just object[key].

Iterating through all elements

It's expected that Map will be a clear winner here, because Object.entries takes O(n) time and we need to iterate through all elements – again O(n) time. Meanwhile, Map.prototype.entries() will return an iterator. But let's take a look at the numbers to prove our hypothesis.

SizeIteration #Object (ms)Map (ms)Diff (%)
10010,3590,0281282%
20,3550,0281268%
30,3590,054665%
40,3420,041834%
avg0,3540,038937%
1_00013,4140,1921778%
23,310,1911733%
33,3270,1941715%
43,3010,1871765%
avg3,3380,1911748%
100_0001526,02444,0841193%
2495,09443,9561126%
3560,78155,1351017%
4563,5247,2011194%
avg536,35547,5941127%
200_00011197,03670,0741708%
21191,57671,351670%
31334,452138,176966%
41321,185125,7321051%
avg1261,062101,3331244%
500_00014482,695404,5811108%
24492,36401,1581120%
34497,207277,5781620%
44474,672249,6991792%
avg4486,734333,2541346%

Personally, I had hoped to see more linear characteristics of Object, but it is what it is. Map is ~12.8 times (!!!) faster. Even if O(n) + O(n) for Object is still O(n), the numbers don’t lie.

Clearing the map

Finally, the last operation - deletion. Again, it's algorithmic constant time. Should it be straightforward, or not?

SizeIteration #Object (ms)Map (ms)Diff (%)
10010,1290,096134%
20,1180,092128%
30,1150,104111%
40,1150,102113%
avg0,1190,099121%
1_00010,9730,686142%
20,9680,695139%
30,9470,74128%
40,9950,792126%
avg0,9710,728133%
100_0001128,3396,55133%
2130,58897,79134%
3130,53798,16133%
4129,546100,045129%
avg129,75098,136132%
200_0001303,532244,151124%
2305,504246,433124%
3300,576233,783129%
4309,844240,641129%
avg304,864241,252126%
500_0001787,313798,61899%
2782,392802,13598%
3767,072836,07392%
4766,472844,48991%
avg775,812820,32995%

No surprises this time. Map is faster by 22%. But again delete object[key] is a little bit faster, than Map.prototype.delete() for 500k elements.

Memory consumption

This time let’s compare memory consumption. As for key, I’ll use hashed string (to avoid key collisions) and as for values just a string like in tests above. Again, key-value pairs are the same for both Object and Map implementations. For each test there will be 1 iteration, since these are just memory allocations.

SizeObject (KB)Map (KB)Diff (%)
10_00060870486%
100_000121927024174%
200_0001846414208130%
500_0004305621536200%
1_000_0008606443040200%

To explain why the numbers are so different, let's dive under the hood.

What’s under the hood?

Object

In the V8 JavaScript engine, an Object is a dynamic collection of properties. To optimize performance, V8 represents objects using hidden classes and inline caching. When an object is created, V8 assigns a hidden class to that object. As properties are added to the object, the hidden class transitions to new hidden classes to reflect the current state of the object.

For example, if we create an empty object:

const x = {};

V8 creates an initial hidden class (C0). If we then add a property to the object:

x.a = 1;

V8 transitions from the hidden class C0 to a new hidden class (C1), which represents an object with property a. This system allows V8 to use the hidden class of an object to quickly look up properties and improve the speed of property access.

However, this system isn't as efficient when properties are deleted from an object. When a property is deleted, V8 can't revert to the previous hidden class. Instead, it switches to a slower mode of operation where properties are accessed via a dictionary-like data structure. This is why deleting properties from an object is slower compared to other operations.

In addition, since Object is not specifically designed for use as a hash map, it does not have built-in methods for common map operations. As we've seen from our tests, workarounds for insufficient methods can drastically affect performance.

Who can tell more about hidden classes than the V8 developers? Here is a detailed article about the nature of hidden classes in V8.

Map

In contrast, Map uses a hash table data structure underneath. A hash table, also known as a hash map, is a data structure that implements an associative array abstract data type, a structure that can map keys to values. Hash tables use a hash function to compute an index into an array of buckets or slots, from which the desired value can be found. This makes it a fast and efficient data structure for finding, inserting, and deleting data.

Since a classic implementation of a hash map (with a hash function and buckets) doesn't support ordering, a deterministic hash table algorithm is used.

Talking about hash tables we need to mention rehashing. Rehashing is a technique used in hash tables to manage size and efficiency. It is triggered when the load factor (entries divided by buckets) exceeds a certain limit. The process involves:

  1. Creating a larger hash table.

  2. Computing the hash for each entry in the old table based on the new size.

  3. Placing each entry in the new table at the position determined by its hash.

  4. Deallocating memory for the old table.

This ensures efficient operations but temporarily increases memory usage and operation time.

It's interesting that, unlike languages such as Java or Python, the V8 map implementation can not only increase the bucket count on insertion, but it can also decrease the bucket count on deletion. So that, when we actively working with a lot of insertion and deletion we should always keep in mind the two following things:

  • set() can take O(n) when hash table grows and doing rehashing

  • delete() can take O(n) when hash table shrinks and doing rehashing

In terms of memory structure, unlike Object, V8 uses a more appropriate single fixed-size array with a header, hash table, and data table. The header stores metadata such as entry count, the hash table stores buckets, and the data table stores entries.

If you want to dive deeper, I’d recommend this article about map implementation.

Summary

To summarize the results of the performance testing, the following can be said:

  • For insertion of values, Map is approximately 4.5-5 times faster than Object.

  • For checking if a value exists, Map and Object show similar performance.

  • For getting a particular element by key, Map outperforms Object by about 2.8 times.

  • The map's has() and get() methods take almost the same amount of time, while Object.hasOwnProperty() is significantly faster than object[key].

  • In terms of iterating through all elements, Map is approximately 12.8 times faster than Object.

  • For deleting properties, Map is faster by 22%.

  • In terms of memory consumption, both Map and Object use a significant amount of memory, but Map is more efficient.

Obviously, it's highly recommended to use Map for key-value storage in the new code, instead of Object, especially if you work a lot with entry iteration.

Do you need to refactor your Object code to use Map? If you don't use huge maps with a lot of iterations through entries – I'd say to consider a move. But in other cases, definitely not. Again, everything depends on the results of the profiling step.

That's it for today, thank you for your attention. Feel free to write a comment with your favourite JS constructions, and we'll sort out their performance.