
Stop Increasing Your Memory Limits: Why the Linux OOM Killer Is Actually Hunting Your RSS (Not Your Heap)
Learn why scaling your JavaScript heap size often fails to prevent container crashes and how to audit the kernel-level 'badness score' that actually determines which process dies first.
You’ve just bumped your Node.js container limit to 4GB. You’ve set --max-old-space-size=3072 to give the engine plenty of breathing room. Ten minutes later, the pod restarts with an OOMKilled status. You check the logs, but there’s no "JavaScript heap out of memory" error. The process simply vanished.
This happens because the Linux Out of Memory (OOM) Killer doesn't care about your JavaScript heap. It doesn't know what a V8 garbage collector is, and it certainly doesn't care that you've configured your runtime to stay under a certain threshold. The kernel is looking at one thing: Resident Set Size (RSS).
When your container hits its cgroup memory limit, the kernel starts a calculation. It assigns a "badness score" to every process, and the process that provides the most "bang for the buck" in terms of reclaimed memory is the one that gets the SIGKILL. If you keep increasing your heap limits without understanding how RSS works, you're just making your process a more attractive target for the kernel's executioner.
The RSS vs. Heap Disconnect
The most common mistake I see in production environments is treating "Memory Limit" and "Heap Size" as synonyms. They aren't.
In a Node.js context, the heap is the memory where your objects live. When you set --max-old-space-size, you are telling the V8 engine when it should start aggressively garbage collecting to avoid a crash *within* the runtime. But the heap is just a subset of the memory your process actually occupies.
RSS (Resident Set Size) is the portion of memory occupied by a process that is held in RAM. It includes:
1. The V8 Heap: Your strings, objects, and closures.
2. Code Space: The JIT-compiled machine code your JS has become.
3. Buffers: Buffer.alloc() and Buffer.from() allocations (these often live outside the V8 heap).
4. Native Bindings: Memory used by C++ addons (e.g., database drivers, image processing libraries like sharp).
5. Thread Stacks: Every worker thread or internal libuv thread needs its own stack memory.
6. Shared Libraries: The memory occupied by .so files (like OpenSSL).
Here is a quick script to see the gap for yourself. Run this in a project that uses native modules or large buffers:
const process = require('process');
const formatMemory = (bytes) => `${(bytes / 1024 / 1024).toFixed(2)} MB`;
setInterval(() => {
const usage = process.memoryUsage();
console.log('--- Memory Snapshot ---');
console.log(`RSS: ${formatMemory(usage.rss)}`);
console.log(`Heap Total: ${formatMemory(usage.heapTotal)}`);
console.log(`Heap Used: ${formatMemory(usage.heapUsed)}`);
console.log(`External: ${formatMemory(usage.external)}`); // Buffers and native C++
console.log(`ArrayBuffers: ${formatMemory(usage.arrayBuffers || 0)}`);
console.log('-----------------------');
}, 5000);
// Let's simulate some non-heap load
const someBuffers = [];
for (let i = 0; i < 100; i++) {
someBuffers.push(Buffer.alloc(10 * 1024 * 1024)); // Allocate 1GB outside the standard heap
}If you run this with --max-old-space-size=512, you’ll notice that heapUsed stays low, but rss and external skyrocket. If your container limit is 1GB, the OOM Killer will kill this process despite the V8 heap being almost empty.
Why the OOM Killer Picks You: The Badness Score
Linux has a very specific algorithm for deciding who dies first when memory runs out. It calculates a badness score for every process, stored in /proc/[pid]/oom_score.
The score ranges from 0 to 1000. A score of 0 means "never kill this," and 1000 means "kill this immediately." The kernel arrives at this number using a relatively simple logic: it's roughly the percentage of memory the process is using relative to the total available. If your process uses 50% of the available memory, its base score is 500.
But there’s a catch in containerized environments. The kernel factors in the oom_score_adj, a value that can be used to protect or sacrifice certain processes.
You can audit your own process's death-sentence-probability right now. If you're running in a Linux environment (or a container), run:
# Replace 1 with your process ID
cat /proc/1/oom_score
cat /proc/1/oom_score_adjIf oom_score is high, you are on the frontline. If you increase your memory limit from 2GB to 4GB, but you also increase your max-old-space-size to fill that gap, your oom_score remains high. You haven't actually made your app more stable; you've just made the crash happen five minutes later.
The Native Memory Trap
I once spent three days debugging a memory leak in a service that resized images using the sharp library. The JavaScript heap was perfectly flat at 200MB. The container, however, was crashing consistently at 2GB.
The culprit was native memory fragmentation. V8’s garbage collector is great at managing things it knows about. But when you use native C++ bindings, those libraries allocate memory using malloc. V8 doesn't always know how much "pressure" is being built up in that native space.
If you are using libraries like bcrypt, sharp, sqlite3, or even the built-in zlib for compression, your RSS will grow independently of your heap.
Monitoring RSS properly
Stop looking at just heapUsed. You need to monitor the "unaccounted" memory. In a monitoring tool like Prometheus, you should be tracking:
nodejs_external_memory_bytes + process_resident_memory_bytes
If the gap between rss and heapTotal is growing, you don't have a JavaScript leak; you have a native leak or a buffer management problem.
The Kernel's Perspective: Pages, not Objects
Linux manages memory in Pages (usually 4KB). When your application asks for memory, it’s not getting a "variable"; the kernel is mapping virtual memory addresses to physical pages.
The OOM killer looks at the "Total Virtual Memory Size" (VMS) vs "Resident Set Size" (RSS). VMS is often huge and misleading—it represents everything the process *could* theoretically address. RSS is what is actually taking up physical space.
When your process allocates a massive Buffer, Node.js goes to the OS and asks for a chunk of memory. The OS says "Sure, here's some virtual address space." But the OS doesn't actually give you the physical RAM until you write to that memory. This is called "Overcommitting."
The OOM killer often strikes because the OS promised too much memory to too many processes (overcommitment), and now everyone is trying to write to their pages at once. The kernel realizes it's bankrupt and starts looking for the biggest spender to liquidate.
How to actually stop the crashes
If increasing the limit isn't the answer, what is?
1. The 75% Rule
Never set your --max-old-space-size to the same value as your container limit. You need a buffer for the RSS overhead.
* Container Limit: 2GB
* Max Old Space: 1.5GB (75%)
* The Rest: Reserved for Buffers, threads, and OS overhead.
2. Force Garbage Collection for Native Pressure
If you are doing heavy native work (like image processing), you can manually suggest to V8 that it's time to clean up. While global.gc() is usually a bad idea in standard web logic, in heavy data-processing workers, it can save your life.
Run with --expose-gc and call it after a heavy batch:
if (global.gc) {
global.gc();
}3. Use AbortOnUncaughtException and let it crash
Sometimes, the OOM killer hits because your app is in a death spiral. Instead of letting the kernel kill you silently, configure Node.js to crash when it hits an internal limit. This gives you a stack trace or a core dump, which is infinitely more useful than a SIGKILL exit code 137.
node --max-old-space-size=1536 --abort-on-uncaught-exception index.js4. Audit your oom_score_adj
If you are running critical infrastructure (like a monitoring agent) alongside your app in the same pod/node, you can manually lower its OOM score adjustment so the kernel kills the app before the agent.
# This makes the process very unlikely to be killed (requires root)
echo -998 > /proc/self/oom_score_adjCalculating "Badness" in Code
If you want to be proactive, you can write a small utility to check how close you are to the "danger zone" from the kernel's perspective. Here is a Node.js snippet that reads the actual OOM score:
const fs = require('fs');
function getOOMScore() {
try {
const score = fs.readFileSync('/proc/self/oom_score', 'utf8');
const adj = fs.readFileSync('/proc/self/oom_score_adj', 'utf8');
return {
score: parseInt(score.trim(), 10),
adjustment: parseInt(adj.trim(), 10)
};
} catch (e) {
return { error: 'Not a Linux environment' };
}
}
setInterval(() => {
const { score, adjustment } = getOOMScore();
if (score > 600) {
console.warn(`CRITICAL: OOM Score is ${score}. Kernel is likely to kill us soon.`);
// Trigger emergency cleanup or stop accepting new requests
}
}, 10000);The "Transparent Huge Pages" Gotcha
There's a deeper kernel feature called Transparent Huge Pages (THP). It’s intended to improve performance by using 2MB pages instead of 4KB pages, reducing the overhead of the Translation Lookaside Buffer (TLB).
However, THP is a frequent cause of "ghost" memory usage. It can lead to significant memory fragmentation. If Node.js allocates a small amount of memory, but the kernel maps it to a "huge page," you're suddenly wasting a lot of RSS.
If you see your RSS is much higher than expected and you're on a Linux host, check the status of THP:
cat /sys/kernel/mm/transparent_hugepage/enabledIf it's set to always, try changing it to madvise or never at the OS level. Many high-performance databases (like Redis or MongoDB) recommend disabling this, and the same logic applies to memory-heavy Node.js apps.
Final Thoughts
Stop throwing RAM at the problem. If your container is dying with exit code 137, your JavaScript heap configuration is only half the story. The Linux kernel is an objective observer; it doesn't care about your objects, your garbage collector, or your heap limits. It sees a process consuming a high percentage of the available physical pages, and it acts to save the system.
Audit your RSS. Check your native modules. Calculate your "badness score." Most importantly, leave enough room between your runtime's heap and the container's ceiling. If you don't define that gap, the OOM Killer will eventually define it for you.


