Diagnose memory growth in a long lived web process

A service that starts normally and consumes more memory every hour is not automatically suffering from a JavaScript heap leak. It might be retaining objects accidentally, buffering more concurrent work, caching without a bound, or using native memory that does not appear in the heap number you first inspected. Restarting the process clears the symptom while leaving the explanation unknown.
Imagine a notification service whose memory rises during repeated delivery attempts. A larger container postpones the restart, but the growth returns. The productive investigation starts with the lifetime of one job: what it allocates, what owns those allocations, and which references survive completion. This walkthrough uses an illustrative Node.js process; it does not claim a particular production incident or measured improvement.
Distinguish growth from a stable high baseline
Record memory at several points under comparable workload: startup, idle after initialization, during activity, and after activity stops. A process can keep a high baseline because its runtime and libraries have allocated useful structures. That is different from an unbounded increase across repeated equivalent cycles. You need the shape of growth, not just one alarming number.
Keep workload characteristics beside the measurements. Concurrent jobs, payload sizes, connection counts, and cache entries can explain legitimate increases. If incoming work grows faster than completion, memory may reflect an expanding queue rather than permanently retained completed jobs. The mitigation for overload differs from the fix for a leaked reference.
Avoid using forced garbage collection as the first solution. Garbage collection cannot reclaim an object that remains reachable through an application reference. It also does not explain why the application still owns that object. Focus on ownership before tuning runtime flags or changing heap limits.
Measure more than heap usage
Node exposes several memory measurements through process.memoryUsage(). Inspect the relevant categories together rather than equating resident process memory with JavaScript objects. Buffers and other allocations may contribute outside the part of memory represented by heap usage. Treat these measurements as direction for investigation, not as an exact accounting of every operating-system page.
A small diagnostic record can capture the relationship between memory and work. Do not attach complete job bodies or customer identifiers to the record:
function memorySample(activeJobs, cachedEntries) {
const memory = process.memoryUsage();
return {
sampledAt: new Date().toISOString(),
activeJobs,
cachedEntries,
rss: memory.rss,
heapUsed: memory.heapUsed,
external: memory.external,
arrayBuffers: memory.arrayBuffers
};
}
Sample at a bounded interval, send the result through normal observability, and avoid logging it on every tight-loop iteration. Excessive diagnostics can create their own CPU, storage, and allocation cost. The useful question is whether memory returns toward a stable level when active work and temporary buffers return toward zero.
Audit the lifetime of references
List long-lived owners in the process: module-level maps, singleton services, timers, event listeners, connection pools, and closures referenced by pending operations. For each one, ask what gets added, when it gets removed, and whether the removal path runs on success, failure, cancellation, and timeout. Missing failure cleanup is easy to overlook when happy-path tests pass.
In the notification example, a map keyed by job ID might help prevent duplicate in-process work. If failed jobs remain in that map forever, it becomes a permanent archive of payloads. The smallest fix may be deleting entries in a finally block, rather than replacing the queue or adding an external cache. Verify that cleanup does not remove state needed by an actual concurrent job.
Timers and subscriptions deserve the same review. A retry handler that installs a new listener on every attempt can retain both callbacks and captured context. A resource should have an explicit owner and disposal point. Naming those responsibilities often reveals the bug before a heap snapshot is necessary.
Reproduce one workload cycle in isolation
Build a bounded test or staging workload that repeatedly exercises the suspicious path. Keep payload sizes and concurrency deliberate. Include failing operations if growth appears after retries. After a cycle, wait for work to complete and capture the same measurements. Repeat several cycles so you can distinguish initialization effects from continued accumulation.
Do not run an aggressive memory experiment against production just because an AI assistant generated a convenient script. The workload can exhaust the same resources used by real requests. Use an isolated environment, a clear stopping condition, and representative synthetic data. If staging cannot reproduce the issue, compare the missing environmental conditions before making the test more destructive.
Heap snapshots can help identify retained JavaScript objects, but collecting them has operational cost and can expose sensitive process contents. Choose an appropriate environment and handling policy. Compare snapshots across equivalent workload states; a larger object count during active processing does not by itself establish a leak.
Apply a bounded fix rather than hiding growth
For an unbounded cache, define a limit and eviction behavior that match the data's purpose. For a leaked listener, remove it when the owning operation ends. For accumulated buffers, process streams with backpressure rather than holding whole datasets unnecessarily. For excessive concurrency, bound admitted work and define what happens when capacity is exhausted.
Each intervention needs a correctness check. Evicting an entry may break deduplication if the algorithm assumed indefinite retention. Clearing all listeners may remove listeners owned by another component. Streaming a response may require different error behavior after headers are sent. A small fix is safe because its ownership and consequences are understood, not merely because it changes fewer lines.
Increasing a memory limit can be a temporary operational measure when supported by evidence and a recovery plan. Record why it is necessary and how you will know it has become unnecessary. Do not describe it as resolving a leak unless memory stabilizes across the workload that previously caused growth.
Ask AI for ownership analysis, not a guessed leak
Provide a reduced lifecycle diagram and the relevant allocation and cleanup code. Ask an assistant to enumerate the references that survive each outcome and propose a reproduction that would falsify its explanation. Require it to distinguish a retained object from a large but bounded allocation. This framing produces a reviewable hypothesis instead of a generic instruction to clear caches.
Keep secrets, tokens, and complete heap snapshots out of an unrestricted prompt. A synthetic object with the same structure is usually enough to reason about reference ownership. Any claim about a runtime API or flag should be checked against the documentation for the Node version actually deployed.
Verify recovery under failures
Repeat the controlled workload after the change and compare equivalent idle states. Confirm resources are released after success, retry exhaustion, cancellation, and shutdown. Then verify that the service still delivers jobs correctly and that observability reports failures rather than quietly discarding them.
Document the evidence and its limits. A stable staging run supports the tested workload; it does not prove that every production allocation is bounded. Maintain the relevant measurements during rollout and use a reversible release plan when the fix changes concurrency or processing behavior. Memory investigation is complete when the ownership explanation and the observed growth agree.
Sources
About the Author

Kamruz
Full Stack Developer
A passionate developer who loves creating modern web applications and sharing knowledge with the community.
More About Me