Skip to content

ishtms/v8-perf

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 

Repository files navigation

I stopped “deleting” and my hot paths calmed down

I stumbled on this while chasing a latency spike in a cache layer. The usual JS folklore says: “don’t use delete in hot code.” I’d heard it before, but honestly? I didn’t buy it. So I hacked up a quick benchmark, ran it a few times, and the results were… not subtle.

Since I already burned the cycles, here’s what I found. Maybe it saves you a few hours of head-scratching in production. (maybe?)

What I tested

Three ways of “removing” stuff from a cache-shaped object:

  • delete obj.prop — property is truly gone.
  • obj.prop = null or undefined — tombstone: property is still there, just empty.
  • Map.delete(key) — absence is first-class.

I also poked at arrays (delete arr[i] vs splice) because sparse arrays always manage to sneak in and cause trouble.

The script just builds a bunch of objects, mutates half of them, then hammers reads to see what the JIT does once things settle. There’s also a “churn mode” that clears/restores keys to mimic a real cache.

Run it like this:

node benchmark.js

Tweak the knobs at the top if you want to model your workload.

My numbers (Node v22.4.1)

Node v22.4.1

Objects: 2,00,000, Touch: 50% (1,00,000)
Rounds: 5, Reads/round: 10, Churn mode: true
Map miss ratio: 50%

Scenario             Mutate avg (ms)   Read avg (ms)   Reads/sec       ΔRSS (MB)
--------------------------------------------------------------------------------
delete property      38.36             25.33           7,89,65,187     228.6
assign null          0.88              8.32            24,05,20,006    9.5
assign undefined     0.83              7.80            25,63,59,031    -1.1
Map.delete baseline  19.58             104.24          1,91,85,792     45.4

Array case (holes vs splice):

Scenario             Mutate avg (ms)   Read avg (ms)   Reads/sec
----------------------------------------------------------------
delete arr[i]        2.40              4.40            45,46,48,784
splice (dense)       54.09             0.12            8,43,58,28,651

What stood out

Tombstones beat the hell out of delete. Reads were ~3Ă— faster, mutations ~40Ă— faster in my runs.

null vs undefined doesn’t matter. Both keep the object’s shape stable. Tiny differences are noise; don’t overfit.

delete was a hog. Time and memory spiked because the engine had to reshuffle shapes and sometimes drop into dictionary mode.

Maps look “slow” only if you abuse them. My benchmark forced 50% misses. With hot keys and low miss rates, Map#get is fine. Iteration over a Map doesn’t have that issue at all.

Arrays reminded me why I avoid holes. delete arr[i] wrecks density and slows iteration. splice (or rebuilding once) keeps arrays packed and iteration fast.

But... why?

When you reach for delete, you’re not just clearing a slot; you’re usually forcing the object to change its shape. In some cases the engine even drops into dictionary mode, which is a slower, more generic representation. The inline caches that were happily serving fast property reads throw up their hands, and suddenly your code path feels heavier.

If instead you tombstone the field, set it to undefined or null; the story is different. The slot is still there, the hidden class stays put, and the fast path through the inline cache keeps working. There’s a catch worth knowing: this trick only applies if that field already exists on the object. Slip a brand new undefined into an object that never had that key, and you’ll still trigger a shape change.

Arrays bring their own troubles. The moment you create a hole - say by deleting an element - the engine has to reclassify the array from a tightly packed representation into a holey one. From that point on, every iteration carries the tax of those gaps.

And...

delete and undefined are not the same thing:

const x = { a: 1, b: undefined, c: null };

delete x.a;
console.log("a" in x); // false
console.log(Object.keys(x)); // ['b', 'c']

console.log(JSON.stringify(x)); // {"c":null}
  • delete → property really gone
  • = undefined → property exists, enumerable, but JSON.stringify skips it
  • = null → property exists, serializes as null

So if presence vs absence matters (like for payloads or migrations), you either need delete off the hot path, or use a Map.

How I apply this now?

I keep hot paths predictable by predeclaring the fields I know will churn and just flipping them to undefined, with a simple flag or counter to track whether they’re “empty.” When absence actually matters, I batch the delete work somewhere off the latency path, or just lean on a Map so presence is first-class.

And for arrays, I’d rather pay the one-time cost of a splice or rebuild than deal with holes; keeping them dense makes everything else faster.

FAQ I got after sharing this in our slack channel

Why is Map slow here?

Because I forced ~50% misses. In real life, with hot keys, it’s fine. Iterating a Map doesn’t have “misses” at all.

Why did memory go negative for undefined?

GC did its thing. ΔRSS is not a precise meter.

Should I pick null or undefined?

Doesn’t matter for performance. Pick one for team sanity.

So we should never delete?

No. Just don’t do it inside hot loops. Use it when absence is part of the contract.

About

Microbenchmarks exploring V8 performance quirks in real-world code.

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published