Site icon yCrash

How to Troubleshoot GC Thrashing in Node.js

Production issues in Node.js applications can be challenging to diagnose, especially when different problems exhibit similar symptoms. To make troubleshooting easier, we’re introducing a Node.js Production Troubleshooting Series, where each article focuses on one specific production issue, explores its underlying causes, and demonstrates a practical, step-by-step approach to identifying the root cause.

GC Thrashing is one of those production issues that can look small on a heap chart and still ruin latency. The process keeps allocating short-lived objects, V8 spends a large share of wall-clock time in garbage collection, and requests feel slow even when App Memory on the host looks quiet. Unlike a memory leak, the heap doesn’t necessarily keep growing until the application runs out of memory. The key sign of GC Thrashing is frequent garbage collection with poor GC throughput.

In this blog, we’ll walk through how GC Thrashing shows up in a Node.js application, what typically causes it, and how to trace it back to the exact root cause. We’ll also simulate the issue in a sample application, capture diagnostic data as it happens, and analyze it step by step to pinpoint what’s going wrong.

What is ‘GC Thrashing’ in Node.js?

Node.js uses V8 to manage JavaScript memory. When your code creates a lot of short-lived objects, V8 has to run scavenges and mark-sweep collections to reclaim that garbage. Some garbage collection is normal. GC Thrashing happens when garbage collection runs so often that it starts taking time away from the application itself. As a result, the application can become slow even though the heap is not continuously growing.

In yCrash, this typically shows up as Poor Throughput in the Garbage Collection report. A high percentage of time spent in GC is a warning sign. Ideally, it should stay below about 5%. You may also see the Heap After GC chart repeatedly go up and down in a sawtooth pattern: the application allocates objects, GC removes them, and the cycle starts again.

This is different from a memory leak. With a leak, objects remain reachable and the amount of memory retained after GC keeps increasing. With GC Thrashing, memory is being collected, but GC is happening too frequently.

What Causes ‘GC Thrashing’ in Node.js?

Here is the usual list of causes for the ‘GC Thrashing’ problem.

  1. Hot-path allocation: Building large temporary arrays, objects, or strings on every request or timer tick.
  2. Allocate-and-discard loops: Creating junk only to throw it away immediately, which forces repeated scavenges.
  3. Churn in workers: Background workers that keep allocating young-generation garbage while the process is under load.
  4. Object shapes that do not reuse: Always creating new objects instead of reusing buffers or pooling where it is safe.
  5. Watching the wrong gauge: Looking only at RSS or App Memory while GC time share is already high.

How to Simulate GC Thrashing in Node.js

To understand how GC Thrashing appears in the diagnostic data, let’s reproduce the issue using a sample Node.js application.

The following program runs an allocation worker that repeatedly creates short-lived objects in short bursts, then periodically drops and rebuilds a modest old-space set. That pattern keeps V8 collecting without pinning the process at the heap limit forever.

``` JS Code (gc-thrashing.js)
const { Worker } = require('worker_threads');
// Alloc worker: create short-lived objects in bursts, keep a modest old-space set,
// then drop and rebuild it so V8 keeps collecting.
function allocWorkerSource() {
return `
let oldGen = [];
let lastMajorAt = Date.now();
function youngBurst() {
const end = Date.now() + 45;
while (Date.now() < end) {
const junk = new Array(768);
for (let i = 0; i < junk.length; i++) {
junk[i] = { i: i, t: i * 1.1 };
}
}
}
function majorCycle() {
oldGen = [];
while (oldGen.length < 1500) {
const chunk = new Array(1024);
for (let i = 0; i < chunk.length; i++) {
chunk[i] = { i: i, pad: 'x'.repeat(32) };
}
oldGen.push(chunk);
}
lastMajorAt = Date.now();
}
function tick() {
youngBurst();
if (Date.now() - lastMajorAt > 5000) {
majorCycle();
}
setTimeout(tick, 30);
}
majorCycle();
tick();
`;
}
class GCThrashingDemo {
constructor() {
this.workers = [];
this.isRunning = false;
}
start() {
if (this.isRunning) return;
this.isRunning = true;
console.log('Starting GC thrashing simulation...');
// One alloc worker creates the GC pressure (short-lived junk + periodic rebuild).
this.workers.push(new Worker(allocWorkerSource(), {
eval: true,
name: 'gc-thrash-alloc-1'
}));
}
stop() {
this.workers.forEach((w) => { try { w.terminate(); } catch (_) {} });
this.workers = [];
this.isRunning = false;
}
}
module.exports = new GCThrashingDemo();
```

How to Capture Diagnostic Data for Troubleshooting GC Thrashing

yCrash is a diagnostic tool that captures runtime performance data from a live Node.js process, including CPU usage, event loop lag, worker thread activity, and call stacks, and turns that data into a report that points to the root cause. To set up yCrash for your Node.js application, follow the steps below:

1. Install yCrash

yCrash is available in both Cloud and On-premises versions. Use one of the options below to get started:

2. Set up the yCrash Node.js hook first

Please note that this is a one-time setup. The hook must be loaded into your Node.js application before yCrash can capture the Node.js internal data that matters for diagnosis, things like worker thread CPU, event loop lag, method profiling, and call stacks. To set this up, follow the Hook Mode steps here: Node.js Diagnostic Capture. This hook setup adds very minimalistic, almost zero overhead to your application. 

3. Launch yc-360 script

Configure and start the yc-360 script to monitor your Node.js process. The full setup and configuration options specific to your deployment are available here: Micro-metrics Monitoring (M3) Mode

When the yc-360 script detects a problem, it captures  360° diagnostic artifacts (GC log, Event loop lags, Worker threads, CPU Profile, Unhandled rejections, Call stack, Active handles, Process, Storage, Kernel, Network, etc.). It uploads them to the yCrash server automatically. yCrash server analyzes the artifacts using advanced pattern recognition and ML algorithms to identify the root cause of the problem.

How to Analyze GC Thrashing Using the Diagnostic Data

In this section, we’ll see how to analyze GC Thrashing using the diagnostic data captured by yCrash. We’ll start with the incident on the calendar dashboard, check the RCA Summary to understand what’s going on, and then drill into the Garbage Collection report and Heap after GC to confirm thrashing rather than a rising leak.

Step 1: Open the incident from the yCrash dashboard

When the yc-360 script detects any performance degradation in the Node.js application, it automatically captures 360° diagnostic data and transmits it to the yCrash server. The yCrash server analyses this data and creates an incident in the yCrash dashboard as shown in the screenshot below. Open that incident from the yCrash calendar dashboard to view the reports.

Fig: yCrash dashboard, incident list view

Step 2: Start with the RCA Summary Page

Before diving into details, let’s start with the RCA Summary. yCrash analyzes the Node.js diagnostic data and brings the key findings together in simple language with their severity levels.


In the RCA Summary, we will focus on two sections:

AI Overview

At the top of the RCA summary report page, you’ll see an AI Overview section. It gives you an AI-generated summary of the incident, which would make even a non-technical person understand the root cause of the problem. Within the AI Overview section, below the AI summary, you can also click on the ‘Dive Deeper in AI Mode’ button to open a chat window to ask any question to AI about this incident.

Fig: RCA Summary highlighting the AI Overview


As shown in the AI Overview above, a large share of runtime went to garbage collection (about 32% in this capture), with a very high allocation rate and tens of thousands of minor collections. The overview also calls out an overloaded worker pool and a blocked event loop. Those matter for context, but the unique GC Thrashing signal is the GC time share.

Issues in application and device

Fig: RCA Summary highlighting the issues in application and device


As shown in the above screenshot, several application-level issues are reported. The finding that matters most for GC Thrashing is:

You will also see Fatal Worker pool overloaded, Fatal Event loop blocked (about 1924 ms), a hot method run near 99% CPU, a duplicate ms package warning, and a small event listener growth warning. Treat those as secondary.

Step 3: Open the Garbage Collection report and confirm Poor Throughput


From the RCA Summary report, click the “detail here” hyperlink present at the end of the Garbage Collection problem statement. This will take you to the Garbage Collection report. Start with the KPI (Key Performance Indicators) section at the top of this report.

Fig: GC report showing Poor Throughput and GC time share

The report shows that the application is spending 32.22% of its time doing garbage collection, leaving about 67.78% for actual application work.

The average GC pause is only 4.87 ms, with the longest pause at 184 ms. So, this isn’t a case of one very long GC pause. Instead, GC is running so frequently that it is taking a significant amount of the application’s time.

The AI Overview gives us a better idea of the scale. In roughly six minutes, the application performed about 23,608 minor garbage collections due to allocation failures. It was also allocating memory at around 1.04 GB per second.

At its peak, the heap was using around 913 MB out of 947 MB. The heap is clearly under heavy pressure, but the application has not yet reached FATAL ERROR: Reached heap limit.

Step 4: Confirm Heap after GC stays thrashing, not leaking


In the same Garbage Collection report, scroll to the Interactive Graphs section.

Fig: Heap Usage (after GC) showing a repeating sawtooth


Over the six-minute period, the graph shows a clear sawtooth pattern. Heap usage keeps increasing, then GC runs and frees a large amount of memory. The same pattern repeats again and again. For more examples of common garbage collection patterns and what they indicate, see our guide to interesting garbage collection patterns.

Notice that after each GC, heap usage drops back to around a few hundred megabytes. It doesn’t keep climbing continuously.

This is an important difference between GC thrashing and a memory leak. With a memory leak, the heap usage after GC keeps getting higher because objects are still being held in memory. Here, GC is successfully reclaiming a large amount of memory, but the application keeps allocating new objects, which causes GC to run again and again.

In short, the problem here is excessive GC activity, not a memory leak.

Step 5: Treat Event Loop Lag as secondary context


Open Node.js Internals report from the left navigation as shown in the screenshot below and scroll to Event Loop Lag. When the application is doing heavy allocation and GC, the main thread can become busy for a while.

Fig: Event Loop Lag showing Fatal spikes during the capture

In this capture, the event loop lag reached 1924 ms at 09:57:24. There were also several Fatal samples between 1050 and 1889 ms. This helps explain why the application felt frozen during this period.

However, event loop lag alone doesn’t tell us the complete story. If we only chased the CPU Profiler hot method, we might think this was simply a CPU spike. The Garbage Collection report gives us the missing piece. It shows that the high CPU usage and event loop lag were caused by frequent GC activity, or GC thrashing.

How to Fix GC Thrashing in Node.js

The following are the potential solutions for this issue:

  1. Reduce allocation in hot paths. Reuse buffers and object pools where it is safe, and avoid building large temporary structures on every request.
  2. Stream or chunk work instead of materializing giant arrays, maps, or strings that exist only for one tick.
  3. Profile allocation (Chrome DevTools Allocation Timeline or equivalent) to find which call sites create the short-lived garbage that forces Scavenge and Mark-sweep.
  4. Watch GC throughput and GC count, not only RSS or heap used. A process can look “small” or “busy” on host gauges while GC thrashing destroys latency.

Conclusion

Diagnosing and identifying the root cause of GC Thrashing can be challenging when host memory gauges look quiet or when CPU charts steal the spotlight. In this example, the diagnostic data was captured using the yc-360 script and analyzed using yCrash server, which helped correlate Poor Throughput with a sawtooth heap-after-GC pattern.

The analysis showed about 32% of runtime spent in GC, throughput near 68%, tens of thousands of minor collections, and a heap that recovered after GC instead of only climbing. The high worker CPU usage and event-loop lag were additional effects of this pressure. The best way to address the problem is to reduce unnecessary short-lived object allocations, especially in frequently executed code. Breaking large workloads into smaller chunks can also reduce the amount of garbage V8 needs to clean up.

Exit mobile version