Dictionary-Mode Objects and the delete Operator

A lookup table implemented as a plain object gets slower and heavier as it grows, a hot object becomes sluggish after a delete, and heap snapshots show objects whose property storage is a large hash table rather than a compact array. This guide from Object Shapes, Strings and Collection Memory Costs, part of JavaScript Memory Fundamentals & Runtime Mechanics, explains V8’s two object representations, what pushes an object into dictionary mode, what that costs, and when to reach for a Map instead.

Symptom Root Cause Immediate Action Measurable Impact
Hot object slower after delete obj.prop Deleting a non-last property switches the object to dictionary mode Set the property to undefined or restructure Object stays in fast mode; inline caches remain effective
Object used as a dynamic key/value store grows heavy Many added/removed keys force dictionary mode Use a Map Predictable memory and fast insert/delete
Heap snapshot shows large properties hash tables Objects in dictionary mode store properties in a hash table Identify the owning objects and their usage pattern Targets only the objects that matter
Records with the same fields have different memory costs Some instances went through delete or late additions Initialise all fields in the constructor Consistent shapes and sizes
Performance cliff after thousands of properties Fast mode has limits on the number of in-object/fast properties Use Map for large, dynamic keyed data No cliff; steady performance

Root Cause: Two Representations, Chosen by How You Use the Object

V8 represents most objects in fast mode: the object points to a hidden class (a “map” in V8 terms) that describes its layout — which properties exist, in what order, and at what offset each value is stored. Property values live in slots inside the object or in a compact backing array. Code that accesses obj.x can cache the offset for a given hidden class in an inline cache, making property access about as fast as a struct field. Objects created the same way share the same hidden class, which is what makes this efficient, as described in how hidden classes and inline caches affect memory.

Some usage patterns do not fit that model. When you delete a property that is not the most recently added one, V8 cannot simply revert to a previous hidden class, so it typically converts the object to dictionary mode (also called slow mode): properties are stored in a per-object hash table keyed by name, and the hidden class no longer describes the layout. Objects that have many properties added dynamically — using an object as a key/value map with thousands of distinct keys — are also moved to dictionary mode, because creating a new hidden class for every key would be wasteful.

Dictionary mode is not “bad”; it is the right representation for a genuine key/value store, and it avoids a hidden-class explosion. But it has costs where you did not intend it. Each dictionary-mode object carries its own hash table with spare capacity, so a small record in dictionary mode can use several times the memory of the same record in fast mode. Property access must hash the key rather than read a cached offset, and inline caches at call sites that see such objects become megamorphic or generic, slowing unrelated code that shares those call sites. Objects rarely return to fast mode on their own.

For data that is truly a dynamic map, use a Map: it is designed for frequent insertion and deletion, keeps insertion order, accepts any key type, and has predictable memory, as compared in Map vs Object vs Array memory overhead. Keep plain objects for records with a fixed set of fields.

Fast mode versus dictionary mode Left: a fast-mode user object points to a shared hidden class that lists id, name and email at fixed offsets; the values sit in three compact slots, and many objects share the same hidden class. Right: after delete user.name, the object switches to dictionary mode; its properties live in a private hash table with spare capacity, access requires hashing, and the shared hidden class no longer applies. Fast mode user object [0] 42 [1] "Ada" [2] "ada@…" hidden class id → 0 name → 1 email → 2 (shared) fixed offsets, cached by inline caches After delete user.name user object dictionary map (not shared) hash table "id" → 42 "email" → "ada@…" + empty buckets per-object table, hashed lookups, more bytes set to undefined instead of delete to keep the fast representation

Step-by-Step Fix

  1. Find objects used as dynamic maps. Search for objects that receive computed keys (obj[key] = value) and delete obj[key] at runtime, especially module-level lookup tables and caches. Verification: you have a list of objects whose key set changes during execution.
  2. Confirm their representation in Node. Run a diagnostic with node --allow-natives-syntax and %HasFastProperties(obj) on a representative instance. Verification: dynamic stores report false (dictionary mode); records that should be fast report true.
  3. Replace dynamic stores with Map. Convert lookup tables, registries and caches with changing keys to Map (get, set, delete, has). Verification: code no longer uses delete on those objects, and iteration uses map.entries().
  4. Stop deleting fields from records. For fixed-shape records, set a field to undefined (or null) instead of deleting it, and declare every field in the constructor or factory. Verification: %HasFastProperties stays true for records after updates.
  5. Measure memory and speed. Compare heap snapshots (shallow and retained size of the objects) and a micro-benchmark of the hot path before and after. Verification: per-record memory falls and hot-path timing improves or stays equal.
  6. Keep shapes consistent across instances. Create records through one constructor or factory that assigns fields in the same order. Verification: snapshots show one shared hidden class for the record type rather than many variants.
Memory per record by representation Approximate heap cost of a record with three fields. Fast mode with in-object properties: about 24 to 32 bytes. The same record in dictionary mode after a delete: about 100 or more bytes because of its private hash table. Storing each record as a value in a Map adds roughly 16 to 24 bytes of table overhead per entry on top of the record. Approximate bytes per 3-field record (varies by V8 version) Fast mode ~24–32 B Dictionary mode ~100+ B Map entry overhead ~16–24 B per entry measure your own shapes with heap snapshots; the ratios matter more than the exact bytes

Command and Code Reference

Use case: check an object’s representation in Node.js (diagnostics only).

// shapes.js — node --allow-natives-syntax shapes.js
const user = { id: 42, name: 'Ada', email: '[email protected]' };
console.log('fast before delete:', %HasFastProperties(user)); // true

delete user.name;                                            // not the last-added property
console.log('fast after delete:', %HasFastProperties(user)); // typically false

const user2 = { id: 43, name: 'Grace', email: '[email protected]' };
user2.name = undefined;                                      // keep the field, clear the value
console.log('fast after undefined:', %HasFastProperties(user2)); // true

Use case: replace an object-as-map with a Map.

// Before: dynamic keys and deletes push the object into dictionary mode
const sessions = {};
function open(id, data) { sessions[id] = data; }
function close(id) { delete sessions[id]; }

// After: a Map is built for exactly this access pattern
const sessionMap = new Map();
function openSession(id, data) { sessionMap.set(id, data); }
function closeSession(id) { sessionMap.delete(id); }

Use case: records with a stable shape.

// Every field declared up front, in the same order, for every instance
class Row {
  constructor(id, label) {
    this.id = id;
    this.label = label;
    this.selected = false;   // declared even if usually false
    this.error = null;       // declared instead of added later
  }
}

Verification and Regression Prevention

Verify with two measurements: a heap snapshot comparing the retained size of the affected collection before and after (dictionary-mode records and object-as-map stores typically shrink noticeably), and a benchmark of the hot path that reads those objects. Also check that the hidden-class count for your record type stays small — in a snapshot, records of one type should share one map rather than dozens of variants.

Prevent regressions with a lint rule against delete on non-Map objects in performance-sensitive modules (for example ESLint’s no-dynamic-delete from typescript-eslint, or a custom rule), and a convention that dynamic key/value data uses Map. Keep %HasFastProperties checks out of production code; they require a special flag and are for investigation only.

Two measurements and one count Compare the retained size of the affected collection before and after, since dictionary-mode records and object-as-map stores typically shrink noticeably. Benchmark the hot path that reads those objects. Check that the hidden class count for the record type stays small. After removing delete from hot objects Retained size The affected collection shrinks in a snapshot diff. Hot-path benchmark Property reads are faster on fast-mode objects. Hidden classes Few shapes for the record type, not one per object.

Edge Cases and Gotchas

Deleting the last-added property

Deleting the most recently added property can sometimes let V8 transition back to the previous hidden class without dictionary mode. Relying on that is fragile; if you need to remove fields, prefer undefined or a Map.

Object spread and rest create new objects

const { name, ...rest } = user creates a new object without name — in fast mode — rather than mutating user. For occasional removal of fields from a copy, this is a clean alternative to delete.

Integer-like keys are elements

Keys such as "42" are stored as indexed elements, not named properties, and follow array-like storage rules, including their own sparse “dictionary elements” mode for very sparse indices. Objects keyed by numeric IDs behave differently from objects keyed by names; a Map avoids both sets of surprises.

Prototype objects

V8 treats objects used as prototypes specially and may keep them in dictionary mode during setup, optimising them later. That is internal behaviour; do not try to influence it.

Frequently Asked Questions

Does the delete operator cause memory leaks?

No — deleted values become collectable. But delete on a non-last property usually converts the object to dictionary mode, which makes that object larger and slower to access. For records, set fields to undefined; for dynamic key sets, use a Map.

What is dictionary mode in V8?

It is the slower object representation in which properties are stored in a per-object hash table instead of at fixed offsets described by a shared hidden class. V8 uses it for objects with many dynamically added or removed properties.

Is Map always better than an object?

For dynamic key/value data with frequent insertions and deletions, usually yes: predictable performance, any key type and no hidden-class churn. For records with a fixed set of named fields, plain objects in fast mode are smaller and faster.

How can I tell if an object is in dictionary mode?

In Node, run with --allow-natives-syntax and call %HasFastProperties(obj) in a diagnostic script. In heap snapshots, dictionary-mode objects show a properties backing store that is a hash table, and they do not share a common map with similar objects.