The symptom was a Node process that climbed from 300MB to 1.4GB over about six hours and then got killed by the container runtime. No deploy correlated with it, and no single route looked guilty.
It turned out to be a closure holding a reference to every request it had ever served. Finding it took a heap snapshot and an afternoon of patience.
#Reading the heap snapshot
We took two snapshots an hour apart under steady traffic and diffed them. The retained size column pointed at a Map that only ever grew, keyed by request URL.
Following the retainers back led to a module-level cache we added to avoid recomputing a navigation tree. It looked harmless because the tree itself was small.
- Take two snapshots, not one, and diff them
- Sort by retained size, then follow the retainer chain upward
- Look for module-level Maps, Sets and arrays first
#The one-line fix
The cache key included the full search string, so every unique query produced a new entry that was never evicted. Tracking parameters alone made the key space effectively infinite.
We keyed on the pathname only and bounded the cache. The memory line has been flat for three weeks.
// before: unbounded, keyed by the full URL
const navCache = new Map<string, NavTree>()
// after: keyed by pathname, bounded
const navCache = new Map<string, NavTree>()
const MAX_ENTRIES = 200
export function getNav(pathname: string): NavTree {
const hit = navCache.get(pathname)
if (hit) return hit
const tree = buildNavTree(pathname)
if (navCache.size >= MAX_ENTRIES) {
navCache.delete(navCache.keys().next().value!)
}
navCache.set(pathname, tree)
return tree
}



