A few years back, I was building a real-time analytics dashboard for a client — tracking tens of thousands of events per minute, each one needing to be looked up, aggregated, and displayed on the fly. The first version used plain JavaScript objects to store the running totals. Within an hour of the first load test, the whole thing was crawling. Not because of the render logic or the WebSocket handling — those were fine. It was the objects. Every time a new event key came in, the browser's JS engine had to de-optimize the object's hidden class, and by the end of the test, we were doing lookups that took milliseconds instead of microseconds.
I spent a Saturday refactoring those objects to Map
instances. Same logic. Same API surface from the rest of the code. But
the dashboard went from unusable under load to comfortably handling
three times the traffic. That was the day I stopped treating Map as an
afterthought and started understanding when to reach for it.
This JavaScript HashMap guide is the article I wish I'd had that
weekend. It covers what a HashMap really means in JavaScript, how
Map works under the hood, when to use it instead of a
plain object, and the real-world patterns that make it worth keeping
in your toolkit.
JavaScript doesn't ship with a class called HashMap. If
you come from Java, C#, or Python, that can be confusing — those
languages give you a dedicated hash-based key-value store right out of
the box. In JavaScript, the term "HashMap" describes a behavior, not a
built-in type. It refers to any structure that gives you constant-time
(O(1)) average-case lookups by key.
The mental model behind a HashMap is simple. You have a hash function that takes a key, computes a numeric index, and stores the value at that position in an underlying array. When you later ask for the value by key, the same hash function computes the same index, and you go straight to it. No scanning, no comparing every entry — just a direct lookup.
Think of it like a coat check at a busy restaurant. You hand over your coat, they give you a numbered ticket, and when you come back, you hand over the ticket and get your coat back. They don't sort through every coat in the closet looking for yours. They go straight to slot number 47 and pull out your coat. A HashMap does exactly the same thing, just with data instead of coats.
In JavaScript, you have three tools that fill this role:
Most developers default to plain objects for everything, simply because that's what they learned first. That's a mistake. Each of these three tools has a specific set of jobs it's better at, and understanding the difference is what separates code that works from code that scales.
When ES6 arrived in 2015, it brought Map — JavaScript's
first data structure built from the ground up for key-value storage.
Unlike plain objects, which carry the weight of Object.prototype
inheritance and string-key coercion, Map was designed for exactly one
purpose: getting and setting values by key, fast.
The differences aren't cosmetic. They show up in ways that matter once your data gets real.
No prototype pollution. Plain objects inherit from
Object.prototype, which means they already have
properties like toString, constructor, and
hasOwnProperty. If you're using user-supplied keys, one
of those could collide with a built-in method name. Map has none of
that — its keys exist in a completely clean namespace.
Any key type, no coercion. Objects convert every key
to a string (or a Symbol). Map preserves the key type exactly. This
means 1 and "1" are separate keys, and you
can use objects, functions, and DOM elements as keys without any
weirdness.
const cache = new Map();
const userObject = { id: 1, name: 'Alice' };
const domElement = document.querySelector('#main');
cache.set(userObject, { lastLogin: new Date() });
cache.set(domElement, { clickCount: 0 });
cache.set(1, 'number one');
cache.set('1', 'string one');
// Different entries — no coercion
console.log(cache.get(1)); // "number one"
console.log(cache.get('1')); // "string one"
console.log(cache.get(userObject)); // { lastLogin: ... }
Guaranteed insertion order. Modern JavaScript engines do maintain property order for most string keys on plain objects, but there are edge cases (particularly with numeric-looking keys that get sorted first). Map's specification guarantees insertion order, full stop, so you can rely on it.
A proper size property. Counting the entries in an
object means Object.keys(obj).length, which allocates an
array every time. Map gives you map.size, which is
computed in constant time.
WeakMap is a specialized cousin of Map that only accepts
objects as keys and holds those keys weakly. If nothing else in your
program is referencing the key object, it can be garbage collected —
and the WeakMap entry disappears with it. This makes WeakMap the
perfect tool for attaching metadata to objects whose lifecycle you
don't control.
// Attach processing metadata without preventing garbage collection
const userMetadata = new WeakMap();
function processUser(user) {
userMetadata.set(user, {
processedAt: Date.now(),
retryCount: 0
});
// When 'user' is no longer referenced elsewhere,
// both the object and its metadata can be collected
}
You can't iterate a WeakMap or check its size. That's the trade-off for the automatic cleanup behavior. If you need to list all the keys or count them, use Map instead.
Creating a Map is about as simple as it gets. The constructor takes an
optional iterable — usually an array of [key, value]
pairs — so you can seed it with initial data if you have it.
// Empty Map
const emptyMap = new Map();
// Initialized with data — each inner array is [key, value]
const settings = new Map([
['theme', 'dark'],
['notifications', true],
['language', 'en-US']
]);
// From an existing object's entries
const user = { name: 'Collin', role: 'Developer', location: 'NJ' };
const userMap = new Map(Object.entries(user));
console.log(userMap.get('name')); // "Collin"
Object.entries() is a great bridge when you already have
object data and want to switch to Map semantics. It returns an array
of [key, value] pairs, which happens to be exactly what
Map's constructor wants.
Unlike objects — where you'd need
Object.keys(obj).length — Map provides a direct
.size property that runs in constant time:
const inventory = new Map([
['apples', 12],
['oranges', 7],
['bananas', 20]
]);
console.log(inventory.size); // 3
Small detail, but it adds up.
Object.keys().length allocates an array of all keys every
time you check it. If you're checking size in a hot loop,
map.size is meaningfully faster.
Map's API is small and consistent. Every operation is a method call, which means no bracket notation ambiguity and no risk of accidentally hitting an inherited property.
.set(key, value) adds a new entry or updates an existing
one if the key is already present. It returns the Map itself, which
lets you chain calls together:
const settings = new Map();
// Add new entries
settings.set('theme', 'dark');
settings.set('fontSize', 16);
// Update an existing key — no error, just overwrites
settings.set('theme', 'light');
// Method chaining for compact bulk updates
settings
.set('notifications', true)
.set('autosave', false)
.set('language', 'en-US');
console.log(settings.get('theme')); // "light"
One thing to note: .set() doesn't complain if you're
overwriting an existing value. It silently replaces it. That's usually
what you want, but if you need to detect "this is an update" vs. "this
is new," check with .has() first.
.get(key) returns the value for that key, or
undefined if the key isn't present. No prototype chain
interference, no inherited defaults — just a clean lookup.
const cache = new Map();
cache.set('user:42', { name: 'Alice', lastSeen: Date.now() });
const userData = cache.get('user:42');
const missingData = cache.get('user:999');
console.log(userData?.name); // "Alice"
console.log(missingData); // undefined
Notice the optional chaining (?.) in that snippet. Since
.get() can return undefined when the key
doesn't exist, optional chaining is the safe way to access nested
properties without throwing.
Map gives you dedicated methods for existence checking, deletion, and clearing. They're all clearer and safer than their object-based equivalents.
With objects, you might write
if (obj[key] !== undefined) or
obj.hasOwnProperty(key). Both have pitfalls. Map's
.has(key) returns a clean boolean with no ambiguity:
const config = new Map([['debug', false], ['port', 3000]]);
if (config.has('debug')) {
console.log('Debug mode configured');
}
// .has() is unambiguous even when the value is falsy
console.log(config.has('debug')); // true
This is the important distinction: on a plain object,
if (obj.debug) evaluates to false both when the property
doesn't exist AND when it exists with a falsy value like
false, 0, or "".
.has() removes that ambiguity entirely.
const data = new Map([
['temp', 'temporary value'],
['persistent', 'keep this'],
['cache-buster', Date.now()]
]);
// Remove a single entry — returns true if the key existed
console.log(data.delete('temp')); // true
console.log(data.delete('nonexistent')); // false
// Remove all entries at once
data.clear();
console.log(data.size); // 0
delete operator on
plain objects can de-optimize the object's hidden class in V8 and
other JS engines, which slows down subsequent property access. Map's
.delete() method is designed for frequent additions and
removals, so it doesn't suffer from the same problem. If your code
adds and removes keys dynamically, Map is the safer choice.
One of Map's strongest features is how naturally it iterates. Maps are
directly iterable, which means for...of works without any
setup. They also provide .forEach() for the functional
crowd, plus dedicated iterators for keys, values, and entries.
const scores = new Map([
['Alice', 95],
['Bob', 82],
['Charlie', 78]
]);
// for...of with destructuring — clean and readable
for (const [name, score] of scores) {
console.log(`${name}: ${score}`);
}
// forEach with value-first callback (matches Array.forEach signature)
scores.forEach((score, name) => {
console.log(`${name} scored ${score}`);
});
// Iterate only keys or only values
const names = [...scores.keys()]; // ["Alice", "Bob", "Charlie"]
const allScores = [...scores.values()]; // [95, 82, 78]
// entries() — same as the default iterator
for (const entry of scores.entries()) {
console.log(entry); // ["Alice", 95], ["Bob", 82], ...
}
A subtle detail worth flagging: .forEach() on a Map
passes (value, key, map) to the callback — matching the
signature of Array.prototype.forEach, which passes
(element, index, array). That consistency makes Maps feel
natural if you're already comfortable with array methods.
This is where most developers get stuck. Both store key-value pairs. Both are used everywhere. So when should you pick which?
JSON.stringify() handles objects directly. Maps need
conversion (and lose their structure in the process), which adds
friction anywhere you're passing data to or from an API.
map.size is
O(1). Object.keys(obj).length is O(n) and allocates an
array.
The performance differences between Map and Object are real, but they're also easy to overstate. Let's look at where each one actually wins.
Both handle individual insertions in effectively constant time. But under heavy insertion workloads — especially when keys are added and removed repeatedly — Map pulls ahead consistently. JavaScript engines optimize plain objects using "hidden classes" (sometimes called "shapes"), and every time you add a new property with a different name or delete an existing one, the object risks de-optimizing out of the fast path into dictionary mode. Once an object is in dictionary mode, every property access is slower.
Map doesn't have this issue. It's designed for dynamic key management, so adding and removing keys doesn't trigger any de-optimization.
For straightforward iteration, modern engines make Map and Object
comparable. But Map's direct iterability means you skip the
intermediate Object.entries() or
Object.keys() calls that allocate temporary arrays, which
can add up in hot loops.
Map typically uses a bit more memory per entry than a plain object, because each entry is a separate object with pointers to the key and value. For datasets in the thousands, this difference is negligible. For datasets in the millions, objects may hold a slight edge — but at that scale, you should be profiling your specific use case anyway.
For most application code — configuration, form data, API responses — the difference is imperceptible. Choose based on API clarity, key types, and iteration needs. Only micro-optimize for Map's performance characteristics when profiling shows a real bottleneck in a hot path.
Caching is the most common real-world use of Map. A time-to-live cache stores values with an expiration timestamp and evicts them automatically on read.
class TTLCache {
constructor(defaultTTL = 60000) {
this.cache = new Map();
this.defaultTTL = defaultTTL;
}
set(key, value, ttl = this.defaultTTL) {
this.cache.set(key, {
value,
expires: Date.now() + ttl
});
}
get(key) {
const entry = this.cache.get(key);
if (!entry) return undefined;
if (Date.now() > entry.expires) {
this.cache.delete(key);
return undefined;
}
return entry.value;
}
has(key) {
return this.get(key) !== undefined;
}
cleanup() {
const now = Date.now();
for (const [key, entry] of this.cache) {
if (now > entry.expires) {
this.cache.delete(key);
}
}
}
}
const apiCache = new TTLCache(30000);
apiCache.set('users:list', fetchedUsers);
function countWords(text) {
const wordCounts = new Map();
const words = text.toLowerCase().match(/\b\w+\b/g) || [];
for (const word of words) {
wordCounts.set(word, (wordCounts.get(word) || 0) + 1);
}
return [...wordCounts.entries()].sort((a, b) => b[1] - a[1]);
}
const topWords = countWords(
'The quick brown fox jumps over the lazy dog. The fox was quick.'
);
console.log(topWords.slice(0, 3));
// [["the", 3], ["quick", 2], ["fox", 2]]
ES2024 introduced Map.groupBy(), which makes grouping
painless on modern runtimes. For older environments, the manual
version is a few lines.
const people = [
{ name: 'Alice', role: 'developer' },
{ name: 'Bob', role: 'designer' },
{ name: 'Charlie', role: 'developer' },
{ name: 'Diana', role: 'manager' }
];
// ES2024+
const byRole = Map.groupBy(people, (person) => person.role);
// Manual fallback for broader compatibility
function groupBy(array, keyFn) {
const groups = new Map();
for (const item of array) {
const key = keyFn(item);
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(item);
}
return groups;
}
function memoize(fn) {
const cache = new Map();
return function (...args) {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
}
const expensiveCalculation = memoize((n) => {
// Some costly work
return n * n;
});
While JavaScript has a dedicated Set for uniqueness, Map
lets you combine uniqueness with per-key metadata:
const visitorLog = new Map();
function recordVisit(userId) {
if (visitorLog.has(userId)) {
const record = visitorLog.get(userId);
record.visitCount++;
record.lastVisit = new Date();
} else {
visitorLog.set(userId, {
firstVisit: new Date(),
lastVisit: new Date(),
visitCount: 1
});
}
}
After reviewing a lot of production JavaScript, I see the same mistakes come up over and over. Here are the ones worth fixing right now.
If your data is going to be serialized to JSON, sent over the wire, or
stored somewhere, a plain object is almost always the better choice.
JSON.stringify(new Map([['a', 1]])) returns
"{}" — an empty object. Silently. Unless you convert it
first, you'll lose your data without warning.
const map = new Map([['a', 1], ['b', 2]]);
// This silently produces an empty object — a classic bug
console.log(JSON.stringify(map)); // "{}"
// Correct: convert to an array of entries first
console.log(JSON.stringify([...map])); // '[["a",1],["b",2]]'
if (map.get(key)) looks innocent but it's wrong if your
values can be falsy. Use if (map.has(key)) instead.
Object.entries() requires a wrapper call. Map iterates
natively. If you're converting between the two repeatedly, that
overhead adds up.
If your keys are known ahead of time and never change — a config object, a set of constants — a plain object is simpler and easier to read. Map earns its keep when keys are dynamic.
const map = new Map();
map.set({ id: 1 }, 'value');
// This returns undefined — different object identity
console.log(map.get({ id: 1 })); // undefined
Two objects with identical contents are different keys in a Map. If you need to key by structural value, serialize to a string first.
JavaScript doesn't have a class called HashMap, but the
Map object introduced in ES6 fills that role. A Map
stores key-value pairs and provides O(1) average-case lookups. Plain
objects can also serve as HashMaps when all keys are strings or
Symbols.
Use plain objects when your keys are known, fixed strings, when you need JSON serialization, or when you're modeling structured data (API responses, config, DTOs). Use Map when keys are dynamic, when you need non-string keys, when insertion order matters, when performance under frequent additions and deletions is important, or when you're building a cache, counter, or lookup table.
Map consistently outperforms Object under workloads with frequent key additions and deletions, because it avoids the hidden-class de-optimization that affects objects with dynamic properties. For simple read-heavy access with fixed keys, both are fast. Only micro-optimize when profiling shows a real bottleneck.
Map holds strong references to its keys, so keys aren't garbage collected as long as the Map exists. WeakMap holds weak references, so if nothing else references the key object, both the key and its entry disappear automatically. WeakMap only accepts objects as keys and is ideal for storing metadata without preventing garbage collection.
Not directly. JSON.stringify returns an empty object for
a Map. To serialize a Map, convert it to an array of entries first:
JSON.stringify([...map]). To deserialize, pass the parsed
array back into new Map().
Use map.has(key), which returns a boolean. Unlike
checking obj[key] !== undefined on plain objects,
map.has() is unambiguous even when the stored value is
false, 0, null, or an empty
string.
Map's get, set, has, and
delete operations all run in O(1) average-case time.
Iteration is O(n). The size property is O(1). This makes
Map well-suited for large collections where fast lookups matter.
The shift from "objects for everything" to "the right tool for each job" is one of those quiet milestones in a JavaScript developer's growth. Map isn't a replacement for objects — it's a complementary tool that shines in specific, common scenarios that plain objects handle poorly.
Here's the mental model to carry forward:
Once you start reaching for Map intentionally — the way I did after that analytics dashboard rewrite — you'll probably notice your code getting cleaner and the performance bugs getting rarer. The API is small and consistent. The behavior is predictable. And the use cases show up everywhere.
If you enjoyed this article, check out our guide on building an Nginx RTMP streaming server or our CSS vendor prefixes guide. As always, if you have any questions or comments, feel free to contact us.