Node 26 shipped V8 14.6, and with it two new methods that have been asked for for years: Map.prototype.getOrInsert() and Map.prototype.getOrInsertComputed(). Both are meant to replace the same five-line idiom everyone has copy-pasted into a cache or a memo table at some point: look up a key, and if it isn't there, compute a default, store it, and return it.

I expected the built-in version to be at least as fast as the hand-written one, since it collapses two separate Map operations into one. It is not. On a warm cache it is about 40% slower than the code it replaces.

That was the first surprise. The second is more useful: the same method quietly gives you cache-stampede protection for async work, for free, with no extra code. The third is that if the computation fails, it keeps the failure cached forever, with no obvious way to know that from the method's name alone.

What's actually new

getOrInsertComputed(key, callbackfn) checks whether key is present. If it is, it returns the existing value and never touches callbackfn. If it isn't, it calls callbackfn(key), stores the return value under key, and returns it. getOrInsert(key, value) does the same thing but takes a plain value instead of a callback. Both exist on WeakMap too.

I ran this on Node 26.13.0 ("Lithium", released 28 October 2026), which bundles V8 14.6.202.34:

$ node --version v26.13.0 $ node -e "console.log(process.versions.v8)" 14.6.202.34-node.40 

The plain pattern it replaces looks like this:

let v = map.get(key); if (v === undefined) { v = compute(key); map.set(key, v); } 

The new one:

const v = map.getOrInsertComputed(key, compute); 

The benchmark, and the number that surprised me

I timed both patterns under two workloads: a cold map where every one of 5,000,000 keys is new (the worst case, all misses), and a warm map where the same 1,000 keys get hit 5,000,000 times (the common case for an actual cache). Five to nine repetitions each, timestamps taken with process.hrtime.bigint(), reporting the fastest and the median run.

Workload get-then-set (median) getOrInsertComputed (median) Slower by
Cold, 5M unique keys 1678ms 1978ms ~18%
Warm, 5M calls over 1,000 keys 29.7ms 41.6ms ~40%

I didn't believe the warm number at first, so I reran it with the two patterns alternating order across nine repetitions, to cancel out any CPU frequency or cache-warming drift, and added a third variant: a hand-written JavaScript function that does exactly what the native method does, called the same way (as an indirect function reference, not inlined in the loop):

function myGetOrInsertComputed(map, key, cb) { let v = map.get(key); if (v === undefined) { v = cb(key); map.set(key, v); } return v; } 
A1 inline get+set : min=1645.5ms median=1772.3ms B1 native getOrInsertComputed: min=1890.8ms median=1952.5ms C1 hand-rolled JS equivalent : min=1452.6ms median=1772.4ms B2 native getOrInsertComputed: min=1716.6ms median=1791.7ms A2 inline get+set : min=1493.2ms median=1633.7ms C2 hand-rolled JS equivalent : min=1556.3ms median=1640.0ms 

The native method was the slowest of the three in both orderings. A plain JavaScript function wrapping get/set and taking the same callback argument was never slower than, and sometimes faster than, the engine's own built-in. V8's JIT has had years to optimise Map.prototype.get and Map.prototype.set as monomorphic, inlineable calls. getOrInsertComputed is new, and on this build it isn't getting the same treatment yet. I have no way to confirm the exact reason from outside the engine, but the pattern across six runs and three independent implementations was consistent enough that I'm confident it isn't noise.

It does buy you something real: deduplicating concurrent work

The place getOrInsertComputed earns its keep isn't raw speed, it's async correctness. If you memoise a promise instead of a resolved value, calling it from several places for the same key before the first call resolves no longer triggers duplicate work, because the method inserts synchronously, before the promise settles:

const cache = new Map(); let fetchCalls = 0; function fetchData(key) { fetchCalls++; return new Promise(resolve => setTimeout(() => resolve(`data-for-${key}`), 30)); } async function get(key) { return cache.getOrInsertComputed(key, fetchData); } const results = await Promise.all([get('x'), get('x'), get('x'), get('y')]); console.log(results, fetchCalls); 
[ 'data-for-x', 'data-for-x', 'data-for-x', 'data-for-y' ] 2 

Three concurrent calls for x and one for y produced exactly two calls to fetchData, not four. That's the manual "in-flight promise map plus a .finally() cleanup" pattern that a lot of server code hand-rolls to avoid a cache-stampede, now free with a correct, built-in single line. This is a genuine win and the strongest reason to reach for the method at all.

But a failure gets cached forever

The same property that makes deduplication work — the engine commits the promise to the map as soon as the callback returns it, without waiting to see whether it resolves or rejects — means a rejected promise sits in the cache exactly like a resolved one would, and nothing evicts it:

let attempts = 0; function flaky(key) { attempts++; return new Promise((resolve, reject) => { setTimeout(() => { if (attempts === 1) reject(new Error(`transient failure #${attempts}`)); else resolve(`ok on attempt ${attempts}`); }, 10); }); } async function tryGet(key) { try { return `OK: ${await cache.getOrInsertComputed(key, flaky)}`; } catch (e) { return `ERROR: ${e.message}`; } } 
call 1: ERROR: transient failure #1 call 2: ERROR: transient failure #1 call 3: ERROR: transient failure #1 total attempts actually made: 1 

flaky() would have succeeded on attempt two. It never got the chance, because the first, failed attempt was already permanently cached under that key. Every later caller gets the same stale rejection, forever, until something deletes the key explicitly. If you reach for getOrInsertComputed to deduplicate network calls, you need a .catch() that calls cache.delete(key) on the failure path yourself; the method gives you none of that for free, and nothing in its name suggests you'd need it.

It isn't transactional either

I assumed the insert step was atomic with respect to the callback: whatever the callback does to the map, the final write should reflect that. It doesn't. The method remembers that the key was absent when it started, runs the callback, and then writes the callback's return value regardless of what happened to that key in between:

const m = new Map(); const result = m.getOrInsertComputed('k', () => { m.set('k', 'set-during-callback'); return 'returned-from-callback'; }); console.log(result, m.get('k')); // returned-from-callback returned-from-callback 

Whatever the callback wrote to 'k' mid-flight is silently discarded and replaced by the callback's own return value. The same thing happens if the callback deletes the key instead of setting it: the delete is undone, and the key comes back holding the callback's return value. If your compute function has any side effect on the same map under the same key — which sounds like an edge case until you consider a callback that recurses, or one shared between two related caches — this is where it bites.

The lazy default is the whole point, and the eager one is a trap

MDN is explicit that getOrInsert() evaluates its second argument eagerly, every call, hit or miss, because that argument is just a normal JavaScript expression evaluated before the method runs — that's not a quirk of the method, it's how function calls work. getOrInsertComputed() only calls its callback on a miss. The two method names are one word apart, which makes it easy to use the cheap-looking one with an expensive default by mistake. I measured the cost of making exactly that mistake, over 200,000 calls against a 100-key warm cache (so the vast majority are hits):

getOrInsert(key, expensiveDefault()) : 531.1ms, 516.5ms, 504.2ms getOrInsertComputed(key, expensiveDefault) : 2.8ms, 1.6ms, 1.9ms 

About 270 times slower, for a cache that is already warm and should be doing almost no work. This confirms the exact warning in MDN's own docs, and it's the kind of mistake that won't show up until the default stops being a one-liner.

What it refuses

Passing something that isn't a function as the callback throws, but the message blames the wrong thing:

> m.getOrInsertComputed('k', 'not-a-function') TypeError: Map.prototype.getOrInsertComputed is not a function 

The method itself is a function — I checked typeof m.getOrInsertComputed first, it's "function". The actual problem is the second argument, but the error reads as if the method lookup failed, which is the one place I'd expect a newcomer to go looking in the wrong file.

WeakMap's versions correctly refuse primitive keys, same as every other WeakMap method:

> new WeakMap().getOrInsert('a-string-key', 1) TypeError: Invalid value used as weak map key 

That one's unsurprising, but worth confirming it wasn't quietly relaxed for the new methods. It wasn't. And if the callback throws, the map is left exactly as it was — no half-written entry, no leftover key — which is the one place everything behaved exactly as I'd have guessed.

What I got wrong on the way

My working assumption going in was that the built-in would win on speed, since it does the lookup once internally instead of twice at the JavaScript level. I was confident enough in that assumption that my first benchmark run only tested the two patterns in one fixed order — old pattern, then new pattern — and when the new one came back slower, I didn't trust it; I assumed I'd built an unfair test, maybe because sequential integer keys from 0 upward make the two Maps grow and rehash at different points depending on which ran first. That's why the final numbers above come from a run with the order alternated nine times, plus a third, hand-rolled control implementation that rules out "the built-in is just unlucky with warm caches" as an explanation. The slowdown held up in both directions and against a plain JS equivalent, so I stopped assuming the theory was obviously true and reported what I actually measured instead.

Run it yourself

This needs Node 26 or later. If your system Node is older, the official binary needs no build step:

curl -s -o node26.tar.xz "https://nodejs.org/dist/v26.13.0/node-v26.13.0-linux-x64.tar.xz" tar -xf node26.tar.xz NODE=./node-v26.13.0-linux-x64/bin/node $NODE --version 

The warm/cold benchmark, trimmed to the essentials:

const N = 5_000_000, KEYS = 1000; function compute(k) { return k * 2; } function timeIt(fn) { const start = process.hrtime.bigint(); fn(); return Number(process.hrtime.bigint() - start) / 1e6; } const oldWarm = () => { const m = new Map(); for (let i = 0; i < KEYS; i++) m.set(i, compute(i)); for (let i = 0; i < N; i++) { const k = i % KEYS; let v = m.get(k); if (v === undefined) { v = compute(k); m.set(k, v); } } }; const newWarm = () => { const m = new Map(); for (let i = 0; i < KEYS; i++) m.set(i, compute(i)); for (let i = 0; i < N; i++) m.getOrInsertComputed(i % KEYS, compute); }; console.log('old:', timeIt(oldWarm), 'ms'); console.log('new:', timeIt(newWarm), 'ms'); 
$NODE bench.mjs 

Every command and snippet above ran on the same machine the numbers came from, including the download.

What to do with this

The one place I'd actually swap in getOrInsertComputed today is the in-flight promise map people already hand-roll around a shared fetch or a shared database lookup. That code usually has its own bugs — a missing .finally(), a key that never gets cleaned up — and this replaces it with something shorter and more obviously correct for the success path. It won't be more obviously correct for the failure path, though. I'd still add .catch(() => cache.delete(key)) around it myself, because this method's name gives you no hint that a single rejection sits there forever otherwise.

I would not swap it in anywhere I cared about raw throughput. A hot cache lookup that currently reads map.get(key) ?? compute-and-set is doing less work than getOrInsertComputed does on this V8 build, not more, and the benchmark above says so plainly. Leave it alone.

And if a teammate reaches for getOrInsert with anything other than a hardcoded constant as the second argument, that's worth a second look in review. The name reads as the lazy, safe default. It isn't; getOrInsertComputed is the one that's lazy, and the two are close enough in spelling that autocomplete will happily suggest the wrong one at 2am.