~/blog

Map.getOrInsert(key, []) builds the default even when the key exists

published

#javascript#node#map#performance

TL;DR

map.getOrInsert(key, defaultValue) evaluates defaultValue before the method runs, so getOrInsert(key, new Set()) builds a Set on every call and discards it whenever the key is already there. Use getOrInsertComputed(key, () => new Set()) for anything that is not a cheap primitive. Both methods also treat a stored undefined as present, which is not what the map.get(key) ?? fallback idiom they replace did.

The problem

The upsert methods reached every major browser by February 2026 and shipped in Node.js 26, and the first example most people copy is the grouping one from the proposal README:

grouped.getOrInsert(key, []).push(...values)

It reads like “use the existing array, or make one if this is a new key”. It is not. Count how many buckets get built for five words and two distinct keys:

let built = 0
function newBucket() {
  built++
  return []
}

const words = ['a', 'b', 'a', 'a', 'b']

const eager = new Map()
for (const w of words) eager.getOrInsert(w, newBucket()).push(w)
console.log('getOrInsert:        ', built, 'buckets built for', eager.size, 'keys')

built = 0
const lazy = new Map()
for (const w of words) lazy.getOrInsertComputed(w, newBucket).push(w)
console.log('getOrInsertComputed:', built, 'buckets built for', lazy.size, 'keys')

On Node v26.2.0:

getOrInsert:         5 buckets built for 2 keys
getOrInsertComputed: 2 buckets built for 2 keys

Five allocations, three of them garbage. With an empty array literal that is a rounding error. With new Set(), structuredClone(template), a parsed config, or a database handle, it is real work done on every lookup, and the more often a key repeats — which is the whole reason you reached for a map — the more of it you throw away.

It is also a regression from the code it usually replaces. Both pre-2026 idioms only build on a miss:

// the long way
let bucket = map.get(w)
if (bucket === undefined) map.set(w, (bucket = newBucket()))
bucket.push(w)

// the short way: ?? only evaluates its right side when the left is nullish
;(map.get(w) ?? map.set(w, newBucket()).get(w)).push(w)

Run over the same five words, each of those builds 2 buckets. Rewriting either one as map.getOrInsert(w, newBucket()) — which looks like a straight tidy-up — takes it to 5.

Why it happens

Nothing about getOrInsert() is lazy. JavaScript evaluates every argument before it calls a function, so by the time the method checks whether the key exists, your default has already been built. The method then does exactly what MDN’s equivalent code says:

if (map.has(key)) {
  return map.get(key)
}
map.set(key, defaultValue)
return defaultValue

MDN’s own advice on the getOrInsert() page is the fix: “If the computation of the default value is expensive, consider using Map.prototype.getOrInsertComputed() instead, which takes a callback to compute the default value only if it’s actually needed.” The callback receives the key, so getOrInsertComputed(id, loadUser) works without a wrapper.

The second surprise is in that same equivalent code: presence is decided by has(), not by the value. A key that is stored with the value undefined counts as found:

const m = new Map([['theme', undefined]])

console.log(m.get('theme') ?? 'dark')        // the ?? idiom
console.log(m.getOrInsert('theme', 'dark'))  // the new method
console.log(m.get('theme'))
dark
undefined
undefined

MDN describes the ??-based alternative, map.set(key, map.get(key) ?? defaultValue), as “slightly less reliable if null or undefined are valid values in your map”. That is true, and it also means swapping one for the other is a behaviour change, not a refactor. If any code path writes map.set(key, undefined) — a cleared preference, a lookup that found nothing — the old idiom filled in the default and the new method hands back undefined.

IdiomBuilds the default on a hit?Stored undefined
map.get(k) ?? map.set(k, d).get(k)no, ?? short-circuitstreated as missing, overwritten with d
map.getOrInsert(k, d)yes, alwaysreturned as undefined, left alone
map.getOrInsertComputed(k, fn)no, fn runs only on a missreturned as undefined, left alone

What to do

Make the callback form the default and keep getOrInsert() for primitives and literals you would not mind building every time:

// fine: 0 is a primitive, nothing to throw away
counts.set(word, counts.getOrInsert(word, 0) + 1)

// use the callback for anything that allocates or does work
const bucket = groups.getOrInsertComputed(key, () => new Set())
bucket.add(item)

getOrInsertComputed() has two behaviours of its own worth knowing before you build a cache on it.

It caches whatever the callback returns, including a rejected promise. The in-flight-request pattern — store the promise so concurrent callers share one fetch — is a natural use, but a failure gets stored too and every later caller receives the same rejection:

const cache = new Map()
let calls = 0
async function load(id) {
  calls++
  throw new Error(`upstream 503 for ${id}`)
}
for (let i = 0; i < 3; i++) {
  try {
    await cache.getOrInsertComputed('user:1', load)
  } catch (err) {
    console.log(`attempt ${i + 1}:`, err.message)
  }
}
console.log('load() ran', calls, 'time(s); cached:', cache.has('user:1'))
attempt 1: upstream 503 for user:1
attempt 2: upstream 503 for user:1
attempt 3: upstream 503 for user:1
load() ran 1 time(s); cached: true

Evict on failure, and only evict the entry you created, so a slow rejection cannot delete a newer request that replaced it:

function getUser(id) {
  return cache.getOrInsertComputed(id, (key) => {
    const pending = load(key).catch((err) => {
      if (cache.get(key) === pending) cache.delete(key)
      throw err
    })
    return pending
  })
}

With a load() that fails once and then succeeds, the first call rejects, the second retries and gets the user, and the third is served from the cache — load() runs twice in total.

If the callback writes the same key, the callback’s return value wins. The spec re-scans the map after the callback returns and overwrites any entry it finds for that key:

const m = new Map()
const v = m.getOrInsertComputed('k', () => {
  m.set('k', 'set inside the callback')
  return 'returned by the callback'
})
console.log(v)
console.log(m.get('k'))
returned by the callback
returned by the callback

That matters for memoised recursive functions only when the recursion can reach the same key. Different keys are unaffected.

If you still support runtimes without the methods, wrap the check once instead of sprinkling has() calls:

function getOrInsertComputed(map, key, compute) {
  if (typeof map.getOrInsertComputed === 'function') return map.getOrInsertComputed(key, compute)
  if (map.has(key)) return map.get(key)
  const value = compute(key)
  map.set(key, value)
  return value
}

Caveats

References