Appearance
3.6.11 — Collections, Weak References, Time & the Modern Standard Library
The parts of JavaScript you use every day and were never formally taught: when a Map beats an object, what a WeakMap is actually for, why Date is the language's most-regretted API, and the modern array and object methods that replace the loops you're still writing. This page closes the standard-library gap — and its WeakMap/WeakRef section is the direct continuation of 3.6.2's reachability lesson and 3.6.9's garbage collector.
1. Map vs plain object — a real decision, not a style preference
javascript
const obj = {}; // keys are strings/symbols ONLY
obj[1] = "a"; obj["1"]; // → "a" ← 1 was silently coerced to "1"
const map = new Map();
map.set(1, "a"); map.get("1"); // → undefined ← 1 and "1" are DIFFERENT keys
map.set({}, "x"); // ← objects as keys: identity-based, impossible with obj
map.size; // → 2 (an object needs Object.keys(o).length)Object | Map | |
|---|---|---|
| Key types | string, symbol only (everything else coerced) | any value, including objects and NaN |
| Ordering | integer-like keys first, ascending, then insertion order | strict insertion order, always |
| Size | Object.keys(o).length — O(n) | map.size — O(1) |
| Iteration | needs Object.keys/entries | directly iterable (3.6.6) |
| Prototype keys | obj["toString"] inherits! Object.create(null) avoids it | no prototype pollution surface |
| Performance | optimized for fixed shapes (3.6.9) | optimized for frequent add/delete |
| JSON | serializes directly | needs [...map] conversion |
Decision rule: use an object for a record with known, fixed keys (a config, a DTO — where V8's hidden classes make it fast). Use a Map for a dictionary — arbitrary, dynamic, or non-string keys, frequent insertion and deletion, or when insertion order matters. And use a Map (or Object.create(null)) whenever keys come from user input, because obj["__proto__"] and obj["constructor"] are a real vulnerability class (9.9.5) that a Map structurally does not have.
Set follows the same logic against arrays: membership testing is O(1) instead of O(n), and deduplication is [...new Set(arr)]. Both use SameValueZero equality — like === except NaN equals NaN, which is what you want and what indexOf doesn't do.
2. WeakMap and WeakSet — keys the garbage collector can take back
A Map holds its keys strongly: as long as the map is alive, every key object in it is reachable and therefore uncollectable (3.6.9). That makes a Map used as a cache keyed by objects a guaranteed memory leak.
WeakMap's deliberate limitations (no iteration, no size, no clear) are not omissions: entries vanish at times determined by the garbage collector, so any API exposing the contents would be non-deterministic.javascript
const meta = new WeakMap(); // key must be an object (or a symbol)
function annotate(node, data) { meta.set(node, data); } // ← attach data WITHOUT touching the node
// when `node` is removed from the DOM and nothing else references it,
// the node AND its metadata entry are collected together — no cleanup code, no leakThe three legitimate uses, and they're the only ones you need: (1) metadata about objects you don't own — annotating DOM nodes, framework internals, or third-party objects without adding properties to them; (2) truly private class state — #private fields have largely replaced this pattern, but a WeakMap keyed by this was how it was done and still appears in libraries; (3) caches keyed by object identity — memoizing an expensive computation per object, where the cache entry should die with the object.
WeakSet is the same for membership ("have I already processed this object?" — useful in graph traversal and cycle detection, where a Set would pin every visited node).
WeakRef and FinalizationRegistry exist and you should almost certainly not use them. A WeakRef holds a reference the collector may clear (ref.deref() returns the object or undefined), and a FinalizationRegistry invokes a callback after collection. The specification itself cautions against them: collection timing is unspecified, callbacks may never run, and code whose correctness depends on GC behaviour is unportable and untestable. Know what they are for interviews; reach for explicit lifecycle management in real code.
3. Time — Date's traps, Intl's answers, Temporal's replacement
Date is a 1995 port of Java's java.util.Date, itself later deprecated. Its trap list is long enough to be famous:
javascript
new Date(2026, 6, 22); // ← July, not June — MONTHS ARE 0-INDEXED
new Date("2026-07-22"); // ← parsed as UTC midnight (date-only ⇒ UTC)
new Date("2026-07-22T00:00"); // ← parsed as LOCAL midnight — a different instant
d.getDay(); // ← day of WEEK (0–6); getDate() is day of month
d1 - d2; // ← works (coerces to ms), but arithmetic on months/DST is wrongThe three rules that prevent most date bugs. ① Store and transmit UTC, in ISO-8601, always — a timestamp without a zone is a bug waiting for a user in another country. ② Convert to local only at the display boundary, using Intl.DateTimeFormat rather than manual formatting. ③ Never do calendar arithmetic with milliseconds — "one day later" is not +86400000 on the days that DST shifts, and "one month later" has no millisecond definition at all.
javascript
new Intl.DateTimeFormat("de-DE", { dateStyle: "long", timeZone: "Europe/Berlin" })
.format(new Date()); // → "22. Juli 2026"
new Intl.NumberFormat("en-IN", { style: "currency", currency: "INR" }).format(125000);
// → "₹1,25,000.00" ← Indian digit grouping
new Intl.RelativeTimeFormat("en").format(-3, "day"); // → "3 days ago"
new Intl.Collator("sv").compare("ä", "z"); // → locale-correct sorting (not codepoint)Intl is the most underused API in the language. It handles pluralization, locale-correct sorting, currency and number formatting, and relative times — every one of which is commonly reimplemented badly. Intl.Collator in particular is the right answer for sorting user-visible strings, since arr.sort() compares UTF-16 code units and puts "Z" before "a" (3.6.7).
Temporal is the replacement API — immutable objects, explicit types (PlainDate, PlainTime, ZonedDateTime, Duration), correct arithmetic, no 0-indexed months. It is reaching browsers and Node now; use it where available, and prefer a well-maintained date library over hand-rolled Date arithmetic everywhere else.
4. Copying, comparing, and structured data
javascript
const shallow = { ...orig }; // ← spread: ONE level deep; nested objects shared
const deep = structuredClone(orig); // ← real deep copy: handles Map, Set, Date,
// ArrayBuffer, and CYCLES
JSON.parse(JSON.stringify(orig)); // ← the old hack: loses undefined, functions,
// Symbol, Map/Set, Date→string, throws on cyclesstructuredClone replaces the JSON round-trip and should be the default for deep copies. It still cannot clone functions, DOM nodes, or class identity (a class Foo instance clones to a plain object), so it is a data copier, not an object copier.
JSON's edge cases deserve naming because they bite silently: undefined values and function properties are dropped from objects but become null inside arrays; Date serializes to a string and does not revive; NaN and Infinity become null; BigInt throws; and key order is preserved but not guaranteed by the spec. The replacer/reviver parameters exist for all of this and are worth knowing — JSON.parse(text, reviver) is how you restore Date objects properly.
5. Modern array and object methods — stop writing those loops
javascript
[1, [2, [3]]].flat(2); // → [1,2,3]
["a b", "c"].flatMap(s => s.split(" ")); // → ["a","b","c"] map + flat(1), one pass
[1,2,3].at(-1); // → 3 ← negative indexing at last
["b","a"].toSorted(); // → ["a","b"] ← NON-MUTATING (also toReversed,
arr.toSpliced(1, 1); // toSpliced, with) — sort() mutates in place!
Object.groupBy(users, u => u.role); // → { admin:[…], user:[…] }
Object.fromEntries(map); // ← Map/entries → object (inverse of Object.entries)
arr.findLast(p); arr.findLastIndex(p); // ← search from the end
str.replaceAll("a", "b"); // ← no /g regex needed
arr.includes(NaN); // → true ← indexOf can't do this (SameValueZero)The non-mutating quartet (toSorted, toReversed, toSpliced, with) is the most valuable recent addition — sort() and reverse() mutating in place is a bug source whenever the array came from somewhere else (a prop, a cached value, a shared module constant), and [...arr].sort() was the awkward workaround for two decades.
reduce deserves a warning. It is the right tool for genuine folds (summing, building a lookup from a list), and the wrong tool the moment the accumulator is an object being mutated across iterations — at which point a for…of loop is shorter, faster, and readable. If a reviewer has to trace the accumulator's shape through three iterations, the reduce has failed.
6. The expert lens
Prototype pollution is a real vulnerability, and collections are the defence. Merging user-supplied JSON into an object (Object.assign(config, userInput), or a recursive deep-merge) lets {"__proto__": {"isAdmin": true}} alter Object.prototype and therefore every object in the program (9.9.5). Defences: use a Map, use Object.create(null) for dictionaries, reject __proto__/constructor/prototype keys explicitly, and validate input against a schema before merging anything.
Equality has four flavours and knowing which applies where prevents whole bug classes: == (coercing — avoid, 3.6.7), === (strict, but NaN !== NaN and 0 === -0), Object.is (SameValue — distinguishes -0, treats NaN as equal), and SameValueZero (used by Map, Set, includes — like Object.is but 0 === -0). This is why [NaN].includes(NaN) is true while [NaN].indexOf(NaN) is -1.
Iteration order is specified, and it surprises people: integer-like keys come first in ascending numeric order, then string keys in insertion order, then symbols. So {"2": "b", "1": "a"} iterates 1, 2 regardless of how you wrote it — one more reason a Map is correct when order matters.
Recall
Mapvs object: object for fixed-shape records (hidden-class fast — 3.6.9);Mapfor dictionaries — any key type, O(1)size, strict insertion order, no prototype surface. Always aMaporObject.create(null)for user-supplied keys (prototype pollution).WeakMap/WeakSethold keys weakly — an object-keyedMapis a guaranteed leak; aWeakMapentry dies with its key. Three uses: metadata on objects you don't own, private state, identity-keyed caches. Not iterable and no.sizeby design (contents change without your code running).WeakRef/FinalizationRegistry: know them, don't use them.Datetraps: months are 0-indexed;"2026-07-22"parses as UTC but"2026-07-22T00:00"as local;getDay()is day-of-week. Store UTC/ISO, convert only at display, never do calendar math in milliseconds. UseIntlfor formatting, sorting (Collator), plurals, and relative times;Temporalis the replacement.structuredClonereplaces theJSONround-trip (handlesMap/Set/Date/cycles; still no functions or class identity). JSON silently dropsundefinedand functions,nulls them inside arrays, stringifiesDate, and throws onBigInt.- Non-mutating array methods —
toSorted,toReversed,toSpliced,with— becausesort()/reverse()mutate in place. Plusflat,flatMap,at(-1),findLast,Object.groupBy,Object.fromEntries. Four equalities:==,===,Object.is(SameValue), SameValueZero (Map/Set/includes— why[NaN].includes(NaN)istrue).
Self-test: Give three reasons to pick a Map over an object. Why is an object-keyed Map a leak and what fixes it? Why can't you iterate a WeakMap? Name three Date traps and the three rules that avoid them. Why does [NaN].includes(NaN) differ from indexOf?
Quiz Bank
FoundationalWhen should you use a Map instead of a plain object?
Five concrete triggers, any one of which decides it. (1) Non-string keys — objects coerce every key to a string, so obj[1] and obj["1"] are the same slot and an object can never be a key; a Map accepts any value including objects and NaN, with identity semantics. (2)
Dynamic or user-supplied keys — a plain object inherits from Object.prototype, so obj["toString"] returns a function you never set, and writing __proto__ can alter every object in the program (prototype pollution — 9.9.5); a Map has no prototype key surface at all, which turns a vulnerability class into a non-issue. (3)
Frequent insertion and deletion — Map is optimized for it, while an object with changing shape repeatedly invalidates V8's hidden classes and inline caches (3.6.9). (4) You need the size, or to iterate — map.size is O(1) and a Map is directly iterable (3.6.6), versus Object.keys(o).length allocating an array to count. (5)
Insertion order matters — a Map preserves it strictly, while an object puts integer-like keys first in ascending numeric order regardless of how you inserted them, so {"2":…, "1":…} iterates 1, 2. When to keep the plain object: a record with a fixed, known set of keys — a config, a DTO, an options bag. That's the shape V8 optimizes hardest, it serializes to JSON directly, it destructures naturally, and it's what every API in the ecosystem expects.
The one-line rule: record → object; dictionary → Map — and if the keys come from outside your program, Map regardless.
AppliedWhy is a Map keyed by objects a memory leak, and how does WeakMap fix it?
Because a Map holds strong references to its keys. Reachability, not scope, decides what the garbage collector may reclaim (3.6.2, 3.6.9): as long as the Map is alive and contains an object as a key, that object is reachable through the map and can never be collected — even if every other reference to it is gone. A long-lived Map used as a cache keyed by request objects, DOM nodes, sockets, or user sessions therefore grows monotonically for the life of the process, and it looks innocent because "the cache" and "the leak" are the same line of code. Worse, the values are retained too, and values are often much larger than keys.
A WeakMap holds its keys weakly: the entry does not count as a reference for reachability, so when the last other reference to a key object disappears, the object becomes collectable and its entry vanishes with it — no eviction policy, no cleanup callback, no TTL, no delete call to forget. The metadata's lifetime is exactly the object's lifetime, which is usually what you wanted and could not previously express.
The API's deliberate limitations follow directly: a WeakMap has no size, is not iterable, and cannot be cleared, because entries disappear at moments determined by the garbage collector — any API exposing the contents would return non-deterministic results and would leak GC timing into observable program behaviour, which the language designers refused.
Constraints: keys must be objects (or, recently, symbols) — primitives have no identity to be weak about. When it is not the answer: if you need eviction by size or time rather than by liveness, you want an LRU with a TTL (9.7.30), because a WeakMap gives you no control over when — or whether — anything is ever collected.
InterviewWhy does JavaScript's Date have such a bad reputation, and what's the correct approach to dates today?
Its API is a 1995 port of java.util.Date — an API Java itself deprecated — and it carries the resulting design errors permanently, because the web cannot break them. The specific traps: months are 0-indexed while days and years are not (new Date(2026, 6, 22) is July); new Date("2026-07-22") is parsed as UTC midnight while new Date("2026-07-22T00:00") is parsed as local midnight — the same-looking strings denote different instants, and the resulting off-by-one-day bugs are among the most common in web software; getDay() returns day-of-week while getDate() returns day-of-month; Date objects are mutable, so d.setDate(d.getDate() + 1) changes an object that may be shared; there is no representation for "a date without a time" or "a time without a date," even though those are what calendars and business rules actually use; and arithmetic is in milliseconds, which is simply wrong across DST transitions (a day is not always 86,400,000 ms) and undefined for months.
The correct approach, in three rules. ① Store and transmit UTC in ISO-8601. A timestamp without a zone is a bug waiting for its first user in another country, and "the server's local time" is not a specification. ② Convert to local only at the display boundary, with Intl.DateTimeFormat and an explicit timeZone — never with manual string assembly, which gets month names, ordering, and calendars wrong for most of the world. ③ Never do calendar arithmetic in milliseconds. Use Temporal where available — immutable, explicitly typed (PlainDate for a birthday, ZonedDateTime for a meeting, Duration for a length), with correct arithmetic and 1-indexed months — or a well-maintained library otherwise.
And the modeling point that outranks all of it: decide what a value means before choosing a type. A birthday is a PlainDate with no zone; a meeting is a wall-clock time plus a named zone (so it survives a DST rule change, which storing a UTC instant does not); a log entry is an instant. Storing all three as "a timestamp" is the root cause of most date bugs that survive to production.
StaffA Node service' memory grows steadily until it OOMs after about six hours. Heap snapshots show millions of retained objects. Walk the investigation using this page and 3.6.9.
Establish the shape first: steady linear growth with a fixed restart interval is a retention leak, not a burst — something is accumulating proportional to traffic, and nothing is releasing it. Rule out the boring causes quickly (an unbounded in-memory queue, a growing array of results, log buffering) before the subtle ones.
Then use comparative heap snapshots, which is the only reliable technique (3.6.9): take snapshot A, run known traffic, take snapshot B, and inspect the objects allocated between A and B that are still alive — the delta, not the totals, because totals are dominated by legitimately-live data and tell you nothing. Sort by retained size, then follow the retainer path of the largest offender: that path names the exact reference chain keeping it alive, and it is the answer.
The candidate causes, in the order they occur in practice. (1) An object-keyed Map or Set used as a cache (section 2) — the retainer path ends at a module-level Map, and the fix is a WeakMap if the entries should die with their keys, or a bounded LRU with a TTL if you need size or time eviction (9.7.30); "cache" without an eviction policy is a synonym for "leak." (2)
Listeners never removed — an EventEmitter accumulating handlers per request, each closing over the request and its response (3.8.5); the tell is a MaxListenersExceededWarning (which is a leak detector, not a nag) and retainer paths through an emitter's _events. (3)
Closures capturing more than intended (3.6.2) — a small callback retained in a long-lived structure that closes over a large buffer or an entire request context; the retained-size column reveals this and the source line rarely suggests it. (4)
Buffer views pinning their backing store (3.8.3) — small metadata slices keeping whole uploads alive; heap snapshots show tiny objects retaining huge ArrayBuffers. (5) Timers or promises that never settle, each holding its captured scope. (6)
Module-level accumulation — a const seen = new Set() at module scope, which is process-lifetime by definition. Confirm the diagnosis before shipping the fix: reproduce in a load test, apply the change, and verify that heap size returns to baseline after the traffic stops — a fix that reduces the rate without flattening the curve has found a symptom, not the cause.
Prevent recurrence with three standing measures: a memory-growth alert on RSS/heap trend rather than only on OOM (so the next one is a ticket rather than a page); a soak test in CI that runs sustained traffic and asserts heap returns to baseline, which is the only automated way to catch this class; and a code convention that every cache declares its eviction policy at the point of declaration — WeakMap, bounded LRU, or TTL — with an unbounded collection at module scope treated as a review-blocking finding.
The principle: in a garbage-collected language, memory bugs are never about allocation — they are always about a reference someone forgot they were holding, so the investigation is always "what is the retainer path," and the design rule is that anything long-lived must state how its contents die.
Flashcards
FlashMap vs object
Record with fixed keys → object (hidden-class fast). Dictionary → Map: any key type, O(1) size, insertion order, iterable, no prototype surface. User-supplied keys ⇒ Map or Object.create(null).
FlashWeakMap
Holds keys weakly ⇒ entry dies with the key. Object-keyed Map = guaranteed leak. Uses: metadata on foreign objects, private state, identity caches. No size/iteration BY DESIGN.
FlashDate rules
Months 0-indexed; "2026-07-22" is UTC but "2026-07-22T00:00" is local. Store UTC/ISO · convert only at display via Intl · never do calendar math in milliseconds.
FlashstructuredClone
Real deep copy: handles Map, Set, Date, ArrayBuffer, cycles. Replaces JSON round-trip (which drops undefined/functions, nulls them in arrays, stringifies Date, throws on BigInt).
FlashFour equalities
== (coercing) · === (NaN!NaN, 0=-0) · Object.is (SameValue: -0 distinct, NaN equal) · SameValueZero (Map/Set/includes) — why [NaN].includes(NaN) is true but indexOf is -1.
Scenario Drill
DrillYou're reviewing a PR that merges user-submitted JSON into a server-side config object and caches the result per user session in a module-level Map. Enumerate everything wrong and specify the correct implementation.
Three distinct defects, each independently serious. (1) Prototype pollution — a remote vulnerability. Merging untrusted JSON into a plain object with Object.assign or a recursive deep-merge lets {"__proto__": {"isAdmin": true}} write to Object.prototype, after which every object in the process answers obj.isAdmin === true unless it has its own property. That escalates trivially into authorization bypass, and it has produced real CVEs in widely-used libraries. Defences, layered:
validate against a schema first and reject unknown keys (the primary fix — a merge of validated data is a merge of known keys); explicitly reject __proto__, constructor, and prototype at every level of a recursive merge; use Object.create(null) for any dictionary built from input, since it has no prototype to pollute; and prefer a Map where the data is genuinely key-value rather than a fixed record (section 1). Note that JSON.parse itself creates the __proto__ key as an own property — safe on its own — and that the danger appears in the merge, which is why the fix belongs there.
(2) The module-level Map keyed by session is an unbounded leak (section 2). Sessions arrive, entries accumulate, and nothing removes them: the map grows for the life of the process, retaining not just session IDs but the entire merged config object per user. If the key is a session object, a WeakMap is the right fix, since the cache entry should die with the session. If the key is a session ID string — the common case — WeakMap does not apply and the correct answer is a bounded LRU with a TTL (9.7.30), sized deliberately, so the memory cost has a stated ceiling. The reviewable rule:
any long-lived collection must declare its eviction policy where it is declared. (3) Per-process caching is wrong for a multi-instance service anyway — with N instances behind a load balancer, a config change invalidates one instance's cache and leaves N−1 serving stale data, and users see different behaviour depending on routing (11.4). Either accept it explicitly with a short TTL that bounds the inconsistency window, or move the cache to a shared store with explicit invalidation.
Two more issues a careful reviewer catches. Merging into the shared config object rather than producing a new one mutates global state — one user's request permanently alters the config every subsequent request sees, which is a correctness bug wearing a performance optimization's clothes; the merge must produce a fresh object (structuredClone the base, section 4, or build immutably). And there is likely no depth or size limit on the incoming JSON, so a deeply-nested payload can blow the stack in a recursive merge or exhaust memory — untrusted input needs bounds on size, depth, and key count before it is processed at all.
The correct implementation, stated as a review comment: parse → validate against a schema with unknown keys rejected and depth/size bounded → build a new object from the validated fields only (never a generic deep-merge of arbitrary input) → cache in a bounded LRU with a TTL keyed by session ID, with the eviction policy written at the declaration → and add a regression test that posts {"__proto__": {"polluted": true}} and asserts ({}).polluted === undefined — because the test is what stops this returning in six months when someone refactors the merge.