One of the great advantages of Node.js is its ability to handle thousands of simultaneous connections using an event-based model and asynchronous operations.
One of the great advantages of Node.js is its ability to handle thousands of simultaneous connections using an event-based model and asynchronous operations.
However, this same ease of use can also hide problems of excessive memory consumption as an application grows.
It's common to find applications that work perfectly during initial testing, but start showing slowdowns, frequent Garbage Collector pauses, or even out-of-memory crashes once they begin processing large volumes of data.
Many of these situations aren't related to the number of requests received, but rather to how the data is loaded and kept in memory.
This project demonstrates two extremely common scenarios in Node.js applications:
It also presents more efficient approaches using Streams, cursors, and batch processing.
Before analyzing the scenarios, it's important to understand a few concepts.
Node.js uses the V8 engine, which is responsible for managing the application's memory.
Among the main indicators are:
Where JavaScript objects are stored.
Example:
const users = [];
All objects added to the array take up space in the Heap.
Represents all the memory occupied by the process.
It includes:
That's why RSS is usually larger than the Heap.
This project was created to answer a very common question:
How can you process large volumes of data without consuming hundreds of megabytes of memory?
To do this, two real-world scenarios frequently found in enterprise systems were simulated.
Imagine a system that needs to import CSV files uploaded by users.
Example:
id,name,email
1,John,john@email.com
2,Mary,mary@email.com
...
100,000 records
A naive implementation may work perfectly with small files, but becomes a problem as the data grows.
The most common approach tends to be:
const content = fs.readFileSync('users.csv', 'utf8');
const lines = content.split('\n');
What happens internally?
File
↓
Full buffer
↓
Full string
↓
Array of lines
↓
JavaScript objects
During this process there are multiple copies of the data in memory at the same time.
The larger the file, the greater the impact.
| Metric | Before |
|---|---|
| Peak Heap | 69.48 MB |
| Peak RSS | 139.63 MB |
A much more efficient alternative is to use Streams.
Example:
const stream = fs.createReadStream('users.csv');
Combined with:
readline.createInterface(...)
processing happens line by line.
File
↓
Line 1
Process
Discard
Line 2
Process
Discard
Line 3
Process
Discard
The amount of memory used stops depending on the total size of the file.
| Metric | After |
|---|---|
| Peak Heap | 7.75 MB |
| Peak RSS | 63.08 MB |
| Metric | Savings |
|---|---|
| Heap | 88.8% |
| RSS | 54.8% |
The main advantage is that only a small part of the file stays in memory at a time.
Even if the file grows to:
10 GB
50 GB
100 GB
memory consumption remains practically constant.
Another very common problem occurs during massive queries.
Imagine a table containing:
100,000 records
or even:
1,000,000 records
Many developers use something similar to:
const users = await repository.findAll();
or
SELECT * FROM users;
The database returns all the records at once.
The flow is typically:
Database
↓
100,000 rows
↓
JS objects
↓
Heap
The entire set needs to stay in memory at the same time.
| Metric | Before |
|---|---|
| Peak Heap | 63.50 MB |
| Peak RSS | 129.25 MB |
A more efficient approach consists of processing small groups of records.
Example:
for await (const batch of cursor) {
process(batch);
}
or using an AsyncGenerator:
async function* getUsers() {
...
}
Database
↓
1000 records
Process
Discard
1000 records
Process
Discard
1000 records
Process
Discard
The Garbage Collector can continuously free up memory.
| Metric | After |
|---|---|
| Peak Heap | 10.65 MB |
| Peak RSS | 63.75 MB |
| Metric | Savings |
|---|---|
| Heap | 83.2% |
| RSS | 50.7% |
When large structures remain in memory:
Giant array
Giant objects
Giant strings
the Garbage Collector has to work harder.
This causes:
In web applications, this can directly impact response time for the user.
Streams are recommended for:
Whenever possible, avoid loading entire files into memory.
Cursors are recommended for:
Whenever the data volume can grow significantly.
Some common symptoms:
200 MB
300 MB
500 MB
800 MB
without ever returning to the initial value.
Logs containing:
Mark-Sweep
Scavenge
Allocation Failure
occurring at a high frequency.
Classic error:
FATAL ERROR:
JavaScript heap out of memory
Even without an increase in CPU or database load.
The problem often lies in memory management.
Avoid:
findAll()
on very large tables.
Prefer:
Pagination
Cursors
Streams
Avoid:
fs.readFileSync()
for large files.
Prefer:
createReadStream()
Constantly monitor:
process.memoryUsage()
to identify abnormal behavior.
| Scenario | Heap Savings | RSS Savings |
|---|---|---|
| CSV Files | 88.8% | 54.8% |
| Database | 83.2% | 50.7% |
These numbers show that small architectural changes can drastically reduce memory consumption without needing to increase infrastructure resources.
Excessive memory consumption in Node.js applications is rarely related only to the number of users.
In most cases, the problem lies in how the data is loaded and processed.
Using Streams for files and Cursors for database queries makes it possible to work with large volumes of data while maintaining predictable and controlled memory consumption.
The results obtained in this project show reductions of over 80% in Heap usage, showing how simple architectural decisions can significantly increase the scalability and stability of Node.js applications.
More than just optimizing memory, these techniques help build systems capable of growing without constantly requiring more CPU, more RAM, or larger servers.