Claude Fable 5.1 & GPT-6 Astra packages are live

Memory

Free

Finding and fixing memory problems — leaks versus growth, heap snapshots, streaming instead of buffering, and sizing a runtime inside a container.

196 lines8.2 KB Kimi Performance
targetModels
Kimi K3Kimi K2.6Kimi K2 FamilyFuture Kimi Models
name
memory
category
Performance
description
Finding and fixing memory problems — leaks versus growth, heap snapshots, streaming instead of buffering, and sizing a runtime inside a container.
license
MIT
author
Agent.md maintainers
last-verified
reviewed-by
unreviewed
<!-- Generated from models/_canonical by scripts/build-model-variants.js. Edit the canonical source, not this file. Behavioural profile for Kimi: scripts/model-profiles.json -->

#Scope contract

FILE_ISOLATION: Modify only files inside the scope the task names; report any out-of-scope change instead of making it.


#Purpose

Rules for memory: keeping usage bounded, diagnosing growth, and configuring a runtime so the platform's limits and the runtime's limits agree.

Distinguish two problems that look identical on a graph:

  • A leak — memory that is referenced but never used again. Grows without bound, ends in an OOM kill.
  • Growth — legitimately more data in memory than the container has. Fixed by streaming, paginating or sizing, not by finding a bug.

Confusing them wastes days. The distinguishing signal: after load stops, does usage return to baseline? If yes, it is growth; if no, it is a leak.


#Bound everything that can grow

Almost every leak in application code is one of these:

PatternWhy it growsFix
Module-scope Map/array cacheKeys added, never removedBounded LRU with a max size
Event listener not removedThe handler retains its whole closureRemove on teardown
setInterval never clearedKeeps the timer and its closure aliveclearInterval
Unbounded queue or bufferProducer outpaces consumerBackpressure and a cap
Request-scoped data on a module objectOne entry per request, foreverAsync context
Closures capturing large objectsThe whole object is retained for one fieldExtract the field
Global error/metric accumulatorOne entry per occurrenceAggregate, do not accumulate
ts
// Unbounded — one entry per unique key, forever
const cache = new Map<string, User>();

// Bounded — eviction is the point
import { LRUCache } from "lru-cache";
const cache = new LRUCache<string, User>({ max: 5_000, ttl: 60_000 });

A cache without a max or a TTL is a leak with a friendly name. And in a multi-instance deployment an in-process cache is per-instance anyway — usually it should be Redis. → Performance/caching


#Stream instead of buffering

ts
// Buffers the entire file into memory. Works in testing, OOMs on a real upload.
const data = await fs.promises.readFile(path);
res.send(data);

// Constant memory regardless of size; `pipeline` also cleans up on error
await pipeline(fs.createReadStream(path), res);

The same rule applies everywhere size is caller-controlled:

  • Database result sets — cursor or paginate; SELECT * with no LIMIT on a growing table is a scheduled OOM. → Performance/queries
  • HTTP request bodies — set a size limit (express.json({ limit: "100kb" })), and a decompressed-size cap so a small gzip cannot expand to gigabytes.
  • CSV and export generation — stream rows out; do not build the whole document in memory.
  • JSON parsingJSON.parse on a multi-megabyte payload allocates several times the payload size and blocks the event loop. → Backend/node

Backpressure exists for this. Ignoring write()'s return value lets a fast producer fill memory until the process dies.


#Diagnose with snapshots, not guesses

bash
node --inspect dist/server.js      # then Chrome DevTools → Memory

The method:

  1. Take a heap snapshot at steady state.
  2. Apply load.
  3. Force a GC, wait for it to settle, take a second snapshot.
  4. Compare — look at objects allocated between the two and still retained.
  5. Follow the retainer path of the largest surviving group. That path names the bug.

The retainer path is the whole answer: it shows exactly which reference is keeping the object alive. Reading allocation counts without it tells you what is big, not what is wrong.

Distinguish growth from a leak first, with the question above: does usage return to baseline after load stops?

Container memory is not process memory. container_memory_working_set_bytes includes page cache and off-heap allocations; a process whose heap is flat can still be OOM-killed by native buffers or a memory-mapped file.


#Configure the runtime against the container limit

The runtime does not read cgroup limits by default. It sizes its heap from host memory, then gets killed.

dockerfile
ENV NODE_OPTIONS="--max-old-space-size=768"     # container limit 1Gi, ~75%
RuntimeSetting
Node--max-old-space-size (MB)
JVM-XX:MaxRAMPercentage=75
PythonNo heap limit; control via worker count and batch size
GoGOMEMLIMIT

Leave headroom: the heap limit is not the process's total footprint. Native buffers, the runtime itself, and page cache all live outside it.

Set the container memory limit equal to the request so the pod gets Guaranteed QoS and is evicted last. Memory is incompressible — exceeding a limit is an immediate kill, not a slowdown. → DevOps/kubernetes

Watch container_memory_working_set_bytes against spec.containers.resources.limits.memory, and nodejs_heap_size_used_bytes against --max-old-space-size. A gap that grows between the two means the leak is off-heap — a native buffer, not JavaScript.

Alert on the trend, not the threshold: memory rising steadily across a week with no traffic increase is a leak, and it is visible long before the first OOM.


#Anti-patterns

Anti-patternWhy it failsFix
Unbounded in-process cacheGrows until OOMBounded LRU with TTL
In-process cache in a multi-instance servicePer-instance and unboundedShared cache
Listeners and intervals never cleaned upClosures retained foreverTeardown on unmount/shutdown
Request data on a module-scope objectOne entry per request, foreverAsync context
Reading files or responses fully into memoryOOM at production sizesStream with pipeline
No request body size limitTrivial memory-exhaustion attackFramework limit
No decompressed-size capZip-bomb expansionRatio and absolute limits
Unpaginated result setsCost grows with the tableCursor or paginate
Ignoring stream backpressureFast producer fills memoryRespect write() / use pipeline
Guessing at the leakDays spent on the wrong objectSnapshot diff and retainer path
Reading allocation counts aloneShows what is big, not what leaksFollow retainers
Confusing growth with a leakWrong fix entirelyCheck recovery after load
Heap sized from host memoryOOM-killed under the container limitSet it below the limit
No headroom above the heap limitNative allocations trigger the killSize to ~75%
Memory limit above requestBurstable QoS; evicted earlierLimit equals request
Alerting only on a thresholdThe leak is visible days earlierAlert on the trend

#Checklist

  • Verify: Every in-process cache has a maximum size and a TTL
  • Verify: Shared caches are used instead of per-instance ones where appropriate
  • Verify: Listeners, intervals and subscriptions are removed on teardown
  • Verify: No request-scoped data is stored on module-scope objects
  • Verify: Files, uploads and large responses are streamed, not buffered
  • Verify: Request body size and decompressed size are capped
  • Verify: Database result sets are paginated or cursored
  • Verify: Exports and reports stream rather than building in memory
  • Verify: Stream backpressure is respected
  • Verify: Leaks are diagnosed by snapshot comparison and retainer paths
  • Verify: Growth and leaks are distinguished by recovery after load stops
  • Verify: Container-level memory metrics are monitored, not only heap size
  • Verify: The runtime heap is sized below the container limit with headroom
  • Verify: Container memory limit equals the request
  • Verify: Memory trend is alerted on, not just a threshold