Object Property Storage Internals
Why delete Slows Down Your Entire Object
You've probably heard "don't use delete" but never got a satisfying explanation why. Watch this:
const config = { host: "localhost", port: 3000, debug: true, verbose: false };
// Fast: 100M property accesses in ~200ms
for (let i = 0; i < 100000000; i++) config.host;
delete config.verbose;
// Slow: same 100M accesses now take ~2000ms
for (let i = 0; i < 100000000; i++) config.host;
Same object, same property, 10x slower. The delete operator didn't just remove a property -- it fundamentally changed how V8 stores the entire object. It went from "fast mode" (struct-like) to "slow mode" (hash table). Every property on the object is now slower to access, not just the deleted one. That's the part that surprises people.
Three Storage Modes
Think of V8 object storage as three levels of housing. In-object properties are rooms inside the house — fixed, pre-built, the fastest to access (just walk down the hall). Backing store properties are a detached garage — still on the property, one extra step to get there. Dictionary mode is a storage unit across town — you have to look up the address every time. Most objects live in the house. Some overflow to the garage. delete moves you to the storage unit permanently.
Mode 1: In-Object Properties (Fastest)
When V8 creates an object, it pre-allocates space for a fixed number of properties directly inside the object's memory. These are in-object properties — they live right next to the Map pointer, with zero indirection.
class Point {
constructor(x, y) {
this.x = x; // In-object property at offset 0
this.y = y; // In-object property at offset 1
}
}
const p = new Point(1, 2);
// Memory layout: [Map][x:1][y:2]
// Accessing p.x is a single offset read from the object pointer
V8 typically pre-allocates space for about 10-12 in-object properties (the exact number depends on the constructor and object creation pattern). This is enough for most objects.
Access cost: 1 memory read (pointer + offset = value).
Mode 2: Backing Store (Fast, One Extra Indirection)
When an object exceeds its in-object property slots, additional properties spill into a separate array called the properties backing store:
function createBigObject() {
const obj = {};
// First ~10 properties are in-object
for (let i = 0; i < 20; i++) {
obj['prop' + i] = i;
}
return obj;
}
const big = createBigObject();
// Memory layout:
// [Map][prop0:0][prop1:1]...[prop9:9][BackingStore pointer]
// |
// v
// [prop10:10][prop11:11]...[prop19:19]
Access cost for overflow properties: 2 memory reads (object -> backing store pointer -> value). Still fast — the backing store is a flat array, and the Map knows each property's offset within it.
V8 decides the in-object property count when it first compiles the constructor. If your constructor always assigns 5 properties, V8 allocates space for approximately 5 + a small buffer. If you later add properties dynamically, those go to the backing store. This is why constructors that consistently initialize the same properties produce faster objects.
Mode 3: Dictionary Mode (Slow)
When V8 decides an object is too dynamic to track with a Map and transitions, it switches to dictionary mode (also called slow mode). The object's properties are stored in a hash table instead of a fixed-layout array.
const obj = { a: 1, b: 2, c: 3 };
// Fast mode: properties stored at known offsets via Map
delete obj.b;
// Dictionary mode: properties stored in a hash table
// Every access now requires a hash lookup
Access cost: hash computation + table lookup + comparison (~5-10x slower than fast mode).
What Triggers Dictionary Mode
So what exactly pushes an object into this slow lane? There are a few triggers, and delete is just the most common.
Trigger 1: delete Operator
The most common trigger. Any use of delete on a fast-mode object transitions it to dictionary mode:
const user = { name: "Alice", age: 30, role: "admin" };
delete user.role; // -> dictionary mode
// Alternative: doesn't trigger dictionary mode
const user2 = { name: "Alice", age: 30, role: "admin" };
user2.role = undefined; // Still fast mode! Property exists but is undefined
Trigger 2: Too Many Property Transitions
If V8 detects that a Map's transition tree is growing too large (too many different shapes branching from the same root), it may switch objects with that root Map to dictionary mode:
function createObject(key, value) {
const obj = {};
obj[key] = value; // Different key each time = different transition each time
return obj;
}
// After enough unique keys, V8 gives up and uses dictionary mode
for (let i = 0; i < 1000; i++) {
createObject('key' + i, i); // 1000 different transitions from {}
}
Trigger 3: Computed/Symbol Property Keys in Certain Patterns
Adding many computed or symbol property keys can push objects toward dictionary mode:
const obj = {};
for (let i = 0; i < 100; i++) {
obj[Symbol()] = i; // Unique symbol keys create unique transitions
}
// Eventually transitions to dictionary mode
Trigger 4: Object.defineProperty with Non-Standard Descriptors
Defining properties with non-default attributes (non-writable, non-enumerable, non-configurable) in certain patterns can trigger slow mode:
const obj = {};
Object.defineProperty(obj, 'x', {
value: 1,
writable: false, // Non-default
enumerable: false, // Non-default
configurable: false // Non-default
});
// Depending on the pattern, this may transition to dictionary mode
Dictionary mode objects are "contagious" through inline caches. If you pass a dictionary-mode object to a function that was optimized for fast-mode objects, the IC goes polymorphic or megamorphic. This doesn't make other objects slow — but it makes that function slower for all objects.
function getName(obj) { return obj.name; }
const fast = { name: "Alice", age: 30 };
const slow = { name: "Bob", age: 25 };
delete slow.age; // slow is now dictionary mode
getName(fast); // IC: monomorphic (fast Map)
getName(slow); // IC: polymorphic (fast Map + dictionary mode)
// Now getName is slower even when called with fast objectsNamed Properties vs. Indexed Properties
Here's another detail worth knowing: V8 stores named properties (string keys like obj.x) and indexed properties (integer keys like arr[0]) in completely separate storage:
const arr = [1, 2, 3]; // Indexed: stored in Elements backing store
arr.name = "myArray"; // Named: stored in Properties/in-object
// Memory layout:
// [Map][Elements pointer][Properties/in-object 'name':"myArray"]
// |
// v
// [1, 2, 3] (Elements backing store — typed by element kind)
This separation means:
- Array indices always use the Elements path (optimized for numeric indexing)
- Named properties always use the Properties path (optimized for Map-based lookups)
- They don't interfere with each other's storage mode
Production Scenario: The Configuration Object Antipattern
You'll see this one everywhere in Node.js codebases. A server loads configuration at startup and trims unused entries:
const config = JSON.parse(fs.readFileSync('config.json', 'utf8'));
// Remove environment-specific keys
for (const key of Object.keys(config)) {
if (key.startsWith('dev_') && process.env.NODE_ENV === 'production') {
delete config[key]; // Dictionary mode after first delete
}
}
// config is now in dictionary mode
// Every access in every request handler is slower
function handleRequest(req, res) {
const timeout = config.requestTimeout; // Hash table lookup, not offset read
const maxRetries = config.maxRetries; // Same — slow
// ...
}
The fix: construct a new object instead of deleting properties:
const rawConfig = JSON.parse(fs.readFileSync('config.json', 'utf8'));
const config = {};
for (const key of Object.keys(rawConfig)) {
if (!(key.startsWith('dev_') && process.env.NODE_ENV === 'production')) {
config[key] = rawConfig[key];
}
}
// config is a fresh object in fast mode — no delete was ever used
function handleRequest(req, res) {
const timeout = config.requestTimeout; // Fast offset read
const maxRetries = config.maxRetries; // Fast offset read
}
V8's property descriptor internals
Every property in fast mode is described by a descriptor that includes:
- Name: The property key (interned string or symbol)
- Offset: Where the value lives (in-object slot index or backing store index)
- Attributes: writable, enumerable, configurable (stored as bit flags)
- Representation: How the value is stored (Smi, Double, HeapObject, Tagged)
V8 stores these descriptors in a DescriptorArray pointed to by the Map. When a Map has many properties, this array can be large, but it's shared by all objects with the same Map — so 1,000,000 objects with the same shape share one descriptor array.
In dictionary mode, each property gets its own hash table entry with the full descriptor inline. No sharing, no known offsets. This is why dictionary mode uses more memory and is slower.
Common Mistakes
| What developers do | What they should do |
|---|---|
| Using delete to remove object properties delete transitions the object to dictionary mode — all property access becomes 5-10x slower | Set to undefined, or construct a new object without the unwanted properties |
| Adding hundreds of dynamically-named properties to a single object Too many unique property transitions can trigger dictionary mode. Map is designed for dynamic keys | Use a Map (the data structure) for dynamic key-value collections |
| Thinking only the deleted property is affected delete changes the entire storage mechanism, not just one slot | Dictionary mode affects every property on the object — not just the deleted one |
| Pre-optimizing by using Object.create(null) for all objects Object.create(null) starts in dictionary mode. Regular objects with prototype are faster for fixed-shape data | Only use Object.create(null) when you actually need a prototype-free dictionary (e.g., lookup tables) |
Quiz: Property Storage
Key Rules
- 1V8 stores properties in three modes: in-object (fastest, limited slots), backing store (fast, one extra indirection), dictionary (slow, hash table).
- 2Never use delete on objects in hot paths. Set to undefined instead, or construct a new object without the property.
- 3Dictionary mode is permanent and affects all properties on the object, not just the one that triggered it.
- 4Constructor-initialized properties get in-object slots. Dynamically added properties may spill to the backing store.
- 5Named properties (obj.x) and indexed properties (obj[0]) use completely separate storage systems.
- 6For dynamic key-value collections, use the Map data structure — it's designed for variable keys and won't trigger dictionary mode.