“When would you use a Map instead of a plain object?” This is one of the most common beginner-to-intermediate JavaScript interview questions, and it often arrives disguised as a coding task: “Count the word frequencies in this text,” or “Cache results keyed by the request object.” The interviewer wants to see whether you pick a data structure for a reason, and whether you know the specific ways a plain object can betray you when it is used as a dictionary.
The strongest answers distinguish two jobs: describing a record with known fields, and mapping arbitrary keys to values. Objects are built for the first; Map was added for the second.
Before you start
You should know object literal syntax, bracket access (obj[key]), Object.keys, and what a prototype is at a basic level (objects inherit properties from Object.prototype). Knowing what JSON serialization does is useful for the trade-offs section.
The short answer
Use a Map when keys are dynamic or come from user data, when keys are not strings (objects, numbers you do not want coerced), or when you add and remove entries frequently and need the count. Use a plain object for fixed-shape records whose fields you know when writing the code, and for data that goes straight to JSON. Objects convert every key to a string, inherit keys such as toString and __proto__ from their prototype, and have no size; Map keeps keys as-is, starts truly empty, preserves insertion order and reports size directly.
How it works
An object is a collection of properties. Property keys can only be strings or symbols, so anything else is converted with String(key). That includes objects, which all become "[object Object]".
const userA = { id: 1 };
const userB = { id: 2 };
const viaObject = {};
viaObject[userA] = 'Ada';
viaObject[userB] = 'Bo';
console.log(Object.keys(viaObject)); // [ '[object Object]' ]
console.log(viaObject[userA]); // Bo
const viaMap = new Map();
viaMap.set(userA, 'Ada').set(userB, 'Bo');
console.log(viaMap.get(userA), viaMap.size); // Ada 2
const nums = {};
nums[1] = 'one';
nums['1'] = 'string one';
console.log(Object.keys(nums)); // [ '1' ]
const numMap = new Map([[1, 'one'], ['1', 'string one']]);
console.log(numMap.size); // 2Map compares keys with the SameValueZero algorithm: like ===, except NaN equals NaN. Objects are matched by identity, not by content, so two different { id: 1 } objects are two different keys.
Objects also come with inherited properties. A plain {} has Object.prototype behind it, so lookups for 'constructor' or 'toString' find something even though you never stored it.
const counts = {};
console.log('constructor' in counts); // true
console.log(typeof counts.toString); // function
const m = new Map();
console.log(m.has('constructor')); // falseStep-by-step walkthrough
Step 1: Ask what the keys are
If the keys are a fixed set of field names (name, email, role), you have a record: use an object or a class. If keys come from data (user ids, words in a document, request URLs, DOM nodes), you have a dictionary: lean towards Map. This question settles most cases before any performance talk.
Step 2: Count words safely
Word counting with an object looks fine until the text contains a word that matches an inherited property.
const text = 'the constructor called the builder';
const broken = {};
for (const w of text.split(' ')) broken[w] = (broken[w] || 0) + 1;
console.log(broken.constructor);
// function Object() { [native code] }1
const counts = new Map();
for (const w of text.split(' ')) counts.set(w, (counts.get(w) ?? 0) + 1);
console.log(counts.get('constructor'), counts.get('the')); // 1 2
console.log(counts.size); // 4The object version read the inherited Object function, added 1 to it, and stored a string. The Map version cannot see inherited keys at all.
Step 3: Iterate and measure
A Map is directly iterable in insertion order and has size. Objects need Object.keys or Object.entries first, and their key order has a twist: integer-like keys are listed first in ascending order, then string keys in insertion order.
const obj = { b: 1, 2: 'x', a: 2, 1: 'y' };
console.log(Object.keys(obj)); // [ '1', '2', 'b', 'a' ]
const map = new Map([['b', 1], [2, 'x'], ['a', 2], [1, 'y']]);
console.log([...map.keys()]); // [ 'b', 2, 'a', 1 ]
for (const [key, value] of map) {
if (key === 'a') console.log(key, value); // a 2
}Step 4: Convert at the edges
JSON does not understand Map; JSON.stringify(new Map([['a', 1]])) produces {}. Convert at the boundary with Object.fromEntries and new Map(Object.entries(...)).
const prefs = new Map([['theme', 'dark'], ['lang', 'en']]);
console.log(JSON.stringify(prefs)); // {}
const json = JSON.stringify(Object.fromEntries(prefs));
console.log(json); // {"theme":"dark","lang":"en"}
const back = new Map(Object.entries(JSON.parse(json)));
console.log(back.get('theme')); // darkOnly do this when keys are strings; object keys cannot survive a round trip through JSON.
Worked scenario
An analytics endpoint counted page views per URL path with an object loaded from a database. A crawler requested /__proto__, and the stored count turned into nonsense, while a request for hasOwnProperty crashed a later check that called stats.hasOwnProperty(path).
const stats = {};
function track(path) {
stats[path] = (stats[path] || 0) + 1;
}
track('/home');
track('__proto__');
track('hasOwnProperty');
console.log(Object.keys(stats)); // [ '/home', 'hasOwnProperty' ]
try {
stats.hasOwnProperty('/home');
} catch (e) {
console.log(e.message); // stats.hasOwnProperty is not a function
}Assigning to __proto__ goes through an inherited setter rather than creating a key, so that count was simply lost. Overwriting hasOwnProperty with a number broke the method. Switching to Map fixes both, and gives a correct count of distinct paths for free.
const stats = new Map();
function track(path) {
stats.set(path, (stats.get(path) ?? 0) + 1);
}
['/home', '__proto__', 'hasOwnProperty', '/home'].forEach(track);
console.log(stats.size); // 3
console.log(stats.get('__proto__'), stats.get('/home')); // 1 2
console.log(Object.fromEntries(stats));
// { '/home': 2, ['__proto__']: 1, hasOwnProperty: 1 }If you must use an object as a dictionary, create it with Object.create(null) so it has no prototype, and use Object.hasOwn(obj, key) for checks.
Common mistake
- “Map is always faster.” Engines optimise objects with a stable shape extremely well.
Maptends to win for large dictionaries with frequent additions and deletions; for small fixed records, objects are as fast or faster. Measure before claiming either. - “Objects keep insertion order, so they are the same.” Integer-like keys jump to the front in numeric order.
- Using
map[key] = valueon a Map. That sets an ordinary property on the Map object;get,hasandsizeignore it. - Expecting
JSON.stringifyto handle Map. It yields{}silently. - Expecting content equality for object keys.
map.get({ id: 1 })misses because it is a new object. Use a stable primitive key such as the id.
Verify the behavior
These assertions capture the differences an interviewer is listening for.
import assert from 'node:assert/strict';
const o = {};
o[{}] = 1;
assert.deepEqual(Object.keys(o), ['[object Object]']);
const m = new Map([[{}, 1], [{}, 2]]);
assert.equal(m.size, 2);
assert.equal('toString' in {}, true);
assert.equal(new Map().has('toString'), false);
assert.equal(new Map([[NaN, 'nan']]).get(NaN), 'nan');
assert.equal(JSON.stringify(new Map([['a', 1]])), '{}');
console.log('all assertions passed');
// all assertions passedSave as map-vs-object.mjs and run node map-vs-object.mjs.
Follow-up questions
When would you choose WeakMap? When keys are objects and the entry should disappear once the key object is no longer referenced elsewhere, such as caching computed data per DOM node. WeakMaps are not iterable and have no size.
How do you deep compare two Maps? Check size, then for each [k, v] in one, confirm the other has(k) and the values are equal by your chosen rule.
Is Object.create(null) as good as a Map? It removes inherited keys, but keys are still coerced to strings, there is no size, and integer key ordering still applies.
Can you group with Map directly? Yes, Map.groupBy(items, fn) returns a Map from each key to an array of items, and accepts non-string keys.
Interview exercise
Write firstRepeated(list) that returns the first value which appears a second time, or undefined. The list can contain numbers, strings and object references, and 1 and '1' must be treated as different values.
Answer and reasoning
function firstRepeated(list) {
const seen = new Map();
for (const item of list) {
if (seen.has(item)) return item;
seen.set(item, true);
}
return undefined;
}
const shared = { id: 7 };
console.log(firstRepeated([1, '1', 2, 1])); // 1
console.log(firstRepeated([{ id: 7 }, { id: 7 }])); // undefined
console.log(firstRepeated([shared, 3, shared]) === shared); // trueA plain object would coerce 1 and '1' to the same key and would merge every object into '[object Object]', giving wrong answers. Map keeps the original key types and compares objects by identity, so two separate { id: 7 } literals are distinct while the same reference repeats. The loop is O(n) because Map lookups are effectively constant time. A Set would work equally well here since only membership matters; mentioning that is a bonus.
Continue learning
See more JavaScript interview questions and practise with the JavaScript MCQs. Related notes: Set deduplication and identity, WeakMap and object lifetimes and defending against prototype pollution. Official reference: Map on MDN.