Nodejs

Node.js Performance Optimization Techniques

Practical techniques for optimizing Node.js application performance, covering V8 profiling, Express.js middleware optimization, caching strategies, event loop monitoring, and measurable benchmarking.

Node.js Performance Optimization Techniques

Overview

Performance optimization in Node.js is not about applying a checklist of micro-optimizations and hoping something sticks. It is about measuring first, identifying where the real bottlenecks are, and applying targeted fixes that produce measurable improvements. This article covers the tools, patterns, and V8 internals you need to go from a sluggish Express.js API to one that handles thousands of requests per second -- with proof in the form of actual benchmarks.

Prerequisites

  • Node.js 18+ installed
  • Basic Express.js application experience
  • Familiarity with the Node.js event loop model
  • A working understanding of async/await and callbacks
  • autocannon installed globally (npm install -g autocannon)
  • Redis installed locally (for caching section)

Profiling: Measure Before You Optimize

The cardinal rule of performance work is this: never optimize without profiling first. You will guess wrong about where the bottleneck is roughly 80% of the time.

Using --prof for CPU Profiling

Node.js ships with V8's built-in profiler. No third-party tools required.

node --prof app.js

This generates an isolate-0x....-v8.log file. To make it human-readable:

node --prof-process isolate-0xnnnnnnnnnnnn-v8.log > profile.txt

The output shows you exactly where CPU time is being spent:

 [Summary]:
   ticks  total  nonlib   name
    854   12.1%   14.2%  JavaScript
   4231   59.8%   70.3%  C++
    312    4.4%    5.2%  GC
    621    8.8%          Shared libraries
   1058   15.0%          Unaccounted

 [JavaScript]:
   ticks  total  nonlib   name
    312    4.4%    5.2%  LazyCompile: *processRequest /app/routes/api.js:45:23
    198    2.8%    3.3%  LazyCompile: *JSON.parse native json.js:1:1
     87    1.2%    1.4%  LazyCompile: *serializeResponse /app/utils/serialize.js:12:28

If your GC percentage is above 10%, you have a memory allocation problem. If a single JavaScript function dominates, that is your optimization target.

Using --inspect with Chrome DevTools

For interactive profiling, the --inspect flag opens a Chrome DevTools connection:

node --inspect app.js
Debugger listening on ws://127.0.0.1:9229/a1b2c3d4-e5f6-7890-abcd-ef1234567890
For help, see: https://nodejs.org/en/docs/inspector

Open chrome://inspect in Chrome, click your Node.js target, and go to the "Performance" tab. Record a session while sending traffic to your API, then analyze the flame chart. The width of each bar is proportional to CPU time -- wide bars are your targets.

For production profiling without the overhead of a persistent debugger:

node --inspect=0.0.0.0:9229 --inspect-publish-uid=http app.js

Then connect briefly, capture a 10-second profile, and disconnect.


V8 Engine Optimization Tips

V8 compiles your JavaScript to machine code through a multi-tier pipeline: Ignition (interpreter) feeds into TurboFan (optimizing compiler). Understanding how TurboFan optimizes -- and what causes it to bail out -- is the difference between code that runs at near-native speed and code that stays in the slow interpreter.

Hidden Classes and Object Shape

V8 assigns hidden classes (called "Maps" internally) to objects based on the order and type of their properties. Objects with the same shape share optimization paths. Break the shape, break the optimization.

// BAD: inconsistent object shapes
function createUser(name, age, email) {
    var user = {};
    user.name = name;
    if (age) {
        user.age = age;
    }
    if (email) {
        user.email = email;
    }
    return user;
}

// This creates up to 4 different hidden classes depending on arguments.
// V8 cannot optimize property access across these variations.
// GOOD: consistent object shapes
function createUser(name, age, email) {
    var user = {
        name: name || '',
        age: age || 0,
        email: email || ''
    };
    return user;
}

// Every call produces an object with the same shape.
// V8 optimizes property access once and reuses it everywhere.

Monomorphic vs. Polymorphic Functions

A monomorphic function always receives the same types of arguments. V8 aggressively optimizes monomorphic call sites. A polymorphic function receives different types, forcing V8 to generate slower generic code.

// BAD: polymorphic - receives different types
function formatValue(val) {
    return String(val);
}

formatValue(42);          // number
formatValue('hello');     // string
formatValue(true);        // boolean
formatValue({ x: 1 });   // object
// V8 sees 4 different types and generates a megamorphic call site.
// GOOD: monomorphic - consistent types
function formatNumber(val) {
    return String(val);
}

function formatString(val) {
    return val;
}

// Each function handles one type. V8 optimizes each independently.

You can verify optimization status using V8 flags:

node --trace-opt --trace-deopt app.js 2>&1 | grep "processRequest"
[marking 0x2f3a for optimized recompilation, reason: hot and stable]
[completed optimizing 0x2f3a processRequest]

If you see deopt instead of opt, V8 is bailing out of optimization for that function, and you need to investigate why.

Avoid delete on Objects

Using delete to remove properties from an object changes its hidden class and causes V8 to deoptimize all code that accesses that object shape.

// BAD: causes hidden class transition
var obj = { a: 1, b: 2, c: 3 };
delete obj.b;

// GOOD: set to undefined instead
var obj = { a: 1, b: 2, c: 3 };
obj.b = undefined;

Avoiding Common Performance Pitfalls

Synchronous Operations in Request Handlers

This is the single most common Node.js performance mistake I see in production. One synchronous file read inside a request handler blocks the entire event loop for every concurrent request.

// BAD: blocks the event loop
app.get('/config', function(req, res) {
    var data = fs.readFileSync('/etc/app/config.json', 'utf8');
    res.json(JSON.parse(data));
});
// GOOD: non-blocking
app.get('/config', function(req, res, next) {
    fs.readFile('/etc/app/config.json', 'utf8', function(err, data) {
        if (err) return next(err);
        res.json(JSON.parse(data));
    });
});
// BETTER: read once at startup, serve from memory
var configData = JSON.parse(fs.readFileSync('/etc/app/config.json', 'utf8'));

app.get('/config', function(req, res) {
    res.json(configData);
});

String Concatenation in Loops

String concatenation inside loops creates a new string object on every iteration. For building large strings, use an array and join.

// BAD: O(n^2) string allocation
function buildReport(rows) {
    var output = '';
    for (var i = 0; i < rows.length; i++) {
        output += rows[i].name + ',' + rows[i].value + '\n';
    }
    return output;
}
// GOOD: O(n) array join
function buildReport(rows) {
    var lines = new Array(rows.length);
    for (var i = 0; i < rows.length; i++) {
        lines[i] = rows[i].name + ',' + rows[i].value;
    }
    return lines.join('\n');
}

On a 100,000 row dataset, the array approach finishes in ~15ms versus ~340ms for concatenation.

Unnecessary Closures

Every closure allocates memory for its captured variables. In tight loops or frequently called functions, this adds up.

// BAD: creates a new function object on every request
app.get('/users', function(req, res) {
    db.query('SELECT * FROM users', function(err, rows) {
        var formatted = rows.map(function(row) {
            return {
                id: row.id,
                name: row.first_name + ' ' + row.last_name,
                email: row.email
            };
        });
        res.json(formatted);
    });
});
// GOOD: define the mapper once outside
function formatUserRow(row) {
    return {
        id: row.id,
        name: row.first_name + ' ' + row.last_name,
        email: row.email
    };
}

app.get('/users', function(req, res) {
    db.query('SELECT * FROM users', function(err, rows) {
        res.json(rows.map(formatUserRow));
    });
});

Optimizing Express.js Middleware Chains

Every middleware function in Express runs for every matching request. If you have 15 middleware functions registered before your route handler, that is 15 function calls, 15 context switches, and potentially 15 asynchronous operations per request.

Order Matters

Put cheap middleware first and expensive middleware last. Short-circuit as early as possible.

var express = require('express');
var app = express();

// BAD order: expensive operations run for every request
app.use(bodyParser.json({ limit: '10mb' }));   // parses every request body
app.use(authMiddleware);                        // database lookup
app.use(rateLimiter);                           // Redis lookup
app.use(requestLogger);                         // I/O write

// GOOD order: cheap checks first, short-circuit early
app.use(requestLogger);                         // cheap: writes to buffer
app.use(rateLimiter);                           // fast: in-memory counter check
app.use(authMiddleware);                        // moderate: JWT decode (no DB)
app.use(bodyParser.json({ limit: '10mb' }));   // expensive: only parse if authenticated

Scope Middleware to Routes

Do not apply middleware globally when it only applies to specific routes.

// BAD: parses JSON body for every request including static files
app.use(bodyParser.json());
app.use('/static', express.static('public'));

// GOOD: only parse JSON where needed
app.use('/static', express.static('public'));
app.use('/api', bodyParser.json(), apiRouter);
app.use('/health', function(req, res) {
    res.json({ status: 'ok' });
});

Compress Responses Selectively

The compression middleware is useful, but compressing every response -- including small JSON payloads -- adds CPU overhead that outweighs the bandwidth savings.

var compression = require('compression');

app.use(compression({
    threshold: 1024,                     // only compress responses > 1KB
    filter: function(req, res) {
        if (req.headers['x-no-compression']) {
            return false;
        }
        return compression.filter(req, res);
    }
}));

Database Query Optimization Patterns

The database is almost always the bottleneck. Optimizing your Node.js code while ignoring slow queries is like polishing a car that has no engine.

Connection Pooling

Never create a new database connection per request. Use a connection pool.

var pg = require('pg');

// BAD: new connection per query
app.get('/users', function(req, res, next) {
    var client = new pg.Client(connectionString);
    client.connect(function(err) {
        if (err) return next(err);
        client.query('SELECT * FROM users', function(err, result) {
            client.end();
            if (err) return next(err);
            res.json(result.rows);
        });
    });
});

// GOOD: shared pool with bounded connections
var pool = new pg.Pool({
    connectionString: process.env.DATABASE_URL,
    max: 20,
    idleTimeoutMillis: 30000,
    connectionTimeoutMillis: 2000
});

app.get('/users', function(req, res, next) {
    pool.query('SELECT * FROM users', function(err, result) {
        if (err) return next(err);
        res.json(result.rows);
    });
});

Parameterized Queries and Prepared Statements

Beyond preventing SQL injection, parameterized queries let the database reuse query plans.

// The query plan is cached and reused
pool.query('SELECT * FROM users WHERE id = $1 AND active = $2', [userId, true], function(err, result) {
    // ...
});

Select Only What You Need

// BAD: fetches all columns, including large TEXT blobs
pool.query('SELECT * FROM articles');

// GOOD: fetches only what the list view needs
pool.query('SELECT id, title, slug, published_at FROM articles ORDER BY published_at DESC LIMIT 20');

Caching Strategies

In-Memory Caching with LRU

For data that is read frequently and changes rarely, an in-memory LRU cache eliminates database round trips entirely.

var LRU = require('lru-cache');

var cache = new LRU({
    max: 500,                // max number of entries
    ttl: 1000 * 60 * 5      // 5 minute TTL
});

function getArticle(slug, callback) {
    var cached = cache.get(slug);
    if (cached) {
        return process.nextTick(function() {
            callback(null, cached);
        });
    }

    pool.query('SELECT * FROM articles WHERE slug = $1', [slug], function(err, result) {
        if (err) return callback(err);
        var article = result.rows[0];
        if (article) {
            cache.set(slug, article);
        }
        callback(null, article);
    });
}

Redis Caching for Multi-Instance Deployments

In-memory caching works for a single process. When you have multiple instances behind a load balancer, you need a shared cache.

var redis = require('redis');
var client = redis.createClient({ url: process.env.REDIS_URL });

client.connect();

function getCachedArticle(slug, callback) {
    var cacheKey = 'article:' + slug;

    client.get(cacheKey).then(function(cached) {
        if (cached) {
            return callback(null, JSON.parse(cached));
        }

        pool.query('SELECT * FROM articles WHERE slug = $1', [slug], function(err, result) {
            if (err) return callback(err);
            var article = result.rows[0];
            if (article) {
                client.setEx(cacheKey, 300, JSON.stringify(article));
            }
            callback(null, article);
        });
    }).catch(function(err) {
        // Cache failure should not break the app -- fall through to DB
        console.error('Redis error:', err.message);
        pool.query('SELECT * FROM articles WHERE slug = $1', [slug], function(err, result) {
            if (err) return callback(err);
            callback(null, result.rows[0]);
        });
    });
}

The key principle: cache failure must never break the application. Redis being down should degrade to database reads, not a 500 error.


JSON Parsing and Serialization Performance

JSON is the wire format for most Node.js APIs, and JSON.parse / JSON.stringify are synchronous operations. For large payloads, they can block the event loop for tens of milliseconds.

Measuring the Cost

var payload = [];
for (var i = 0; i < 100000; i++) {
    payload.push({ id: i, name: 'User ' + i, email: 'user' + i + '@example.com', active: true });
}

var start = process.hrtime.bigint();
var serialized = JSON.stringify(payload);
var elapsed = Number(process.hrtime.bigint() - start) / 1e6;
console.log('Stringify: ' + elapsed.toFixed(2) + 'ms, size: ' + (serialized.length / 1024 / 1024).toFixed(2) + 'MB');

start = process.hrtime.bigint();
JSON.parse(serialized);
elapsed = Number(process.hrtime.bigint() - start) / 1e6;
console.log('Parse: ' + elapsed.toFixed(2) + 'ms');
Stringify: 142.38ms, size: 6.87MB
Parse: 98.12ms

142ms of synchronous processing means every other request is queued for 142ms. That is unacceptable at any meaningful concurrency.

Solutions

  1. Paginate: Never serialize 100,000 records. Cap your API at a reasonable page size.
app.get('/api/users', function(req, res) {
    var limit = Math.min(parseInt(req.query.limit) || 50, 200);
    var offset = parseInt(req.query.offset) || 0;
    pool.query('SELECT id, name, email FROM users LIMIT $1 OFFSET $2', [limit, offset], function(err, result) {
        if (err) return res.status(500).json({ error: 'Database error' });
        res.json({ data: result.rows, limit: limit, offset: offset });
    });
});
  1. Use fast-json-stringify: Pre-define your schema, and serialization becomes 2-5x faster by skipping type checking at runtime.
var fastJson = require('fast-json-stringify');

var stringify = fastJson({
    type: 'object',
    properties: {
        id: { type: 'integer' },
        name: { type: 'string' },
        email: { type: 'string' },
        active: { type: 'boolean' }
    }
});

// 2-5x faster than JSON.stringify for known schemas
var output = stringify({ id: 1, name: 'Shane', email: '[email protected]', active: true });
  1. Stream large responses: Instead of building the full JSON array in memory, stream it.
app.get('/api/export', function(req, res) {
    res.setHeader('Content-Type', 'application/json');
    res.write('[\n');

    var cursor = pool.query(new Cursor('SELECT * FROM users'));
    var first = true;

    function readBatch() {
        cursor.read(100, function(err, rows) {
            if (err) {
                res.end(']');
                return;
            }
            if (rows.length === 0) {
                res.end('\n]');
                return;
            }
            for (var i = 0; i < rows.length; i++) {
                if (!first) res.write(',\n');
                res.write(JSON.stringify(rows[i]));
                first = false;
            }
            readBatch();
        });
    }

    readBatch();
});

Buffer and Stream Usage for Large Payloads

When handling file uploads, image processing, or any large binary data, buffers and streams keep memory predictable.

var fs = require('fs');
var crypto = require('crypto');

// BAD: loads entire file into memory
app.post('/upload', function(req, res) {
    var chunks = [];
    req.on('data', function(chunk) {
        chunks.push(chunk);
    });
    req.on('end', function() {
        var buffer = Buffer.concat(chunks);      // entire file in memory
        fs.writeFileSync('/uploads/' + req.headers['x-filename'], buffer);
        res.json({ size: buffer.length });
    });
});

// GOOD: pipe directly to disk
app.post('/upload', function(req, res) {
    var filename = req.headers['x-filename'];
    var hash = crypto.createHash('sha256');
    var output = fs.createWriteStream('/uploads/' + filename);
    var size = 0;

    req.on('data', function(chunk) {
        size += chunk.length;
        hash.update(chunk);
    });

    req.pipe(output);

    output.on('finish', function() {
        res.json({ size: size, sha256: hash.digest('hex') });
    });

    output.on('error', function(err) {
        res.status(500).json({ error: 'Upload failed' });
    });
});

The piped version uses a fixed ~64KB memory buffer regardless of file size. A 2GB upload uses the same memory as a 2KB upload.


Event Loop Monitoring and Blocking Detection

A healthy Node.js event loop should process callbacks in under 10ms. If the event loop lag exceeds 100ms, your API is effectively unresponsive for that duration.

Measuring Event Loop Lag

var lastCheck = process.hrtime.bigint();
var lagSamples = [];

setInterval(function() {
    var now = process.hrtime.bigint();
    var expected = 1000;  // 1000ms interval
    var actual = Number(now - lastCheck) / 1e6;
    var lag = Math.max(0, actual - expected);
    lastCheck = now;

    lagSamples.push(lag);
    if (lagSamples.length > 60) lagSamples.shift();

    var avg = lagSamples.reduce(function(a, b) { return a + b; }, 0) / lagSamples.length;
    if (avg > 50) {
        console.warn('Event loop lag: ' + avg.toFixed(2) + 'ms (avg over ' + lagSamples.length + ' samples)');
    }
}, 1000);

Using monitorEventLoopDelay (Node.js 12+)

Node.js has a built-in histogram-based event loop monitor:

var perf_hooks = require('perf_hooks');

var histogram = perf_hooks.monitorEventLoopDelay({ resolution: 20 });
histogram.enable();

setInterval(function() {
    console.log('Event loop delay:');
    console.log('  min:  ' + (histogram.min / 1e6).toFixed(2) + 'ms');
    console.log('  max:  ' + (histogram.max / 1e6).toFixed(2) + 'ms');
    console.log('  mean: ' + (histogram.mean / 1e6).toFixed(2) + 'ms');
    console.log('  p99:  ' + (histogram.percentile(99) / 1e6).toFixed(2) + 'ms');
    histogram.reset();
}, 10000);
Event loop delay:
  min:  0.01ms
  max:  4.23ms
  mean: 0.18ms
  p99:  1.87ms

If your p99 exceeds 50ms under load, you have blocking operations on the main thread that need to be offloaded to worker threads or moved out of the request path.

Detecting Blocked Event Loop with blocked-at

The blocked-at package pinpoints exactly which line of code is blocking the event loop:

var blocked = require('blocked-at');

blocked(function(time, stack) {
    console.log('Event loop blocked for ' + time + 'ms');
    console.log(stack);
}, { threshold: 50 });
Event loop blocked for 234ms
    at JSON.parse (<anonymous>)
    at processLargePayload (/app/routes/api.js:87:22)
    at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5)

That tells you exactly where to look.


Using clinic.js for Performance Diagnostics

Clinic.js is the Swiss army knife of Node.js performance diagnostics. It wraps --prof, flame charts, and event loop analysis into a single tool with visual output.

Installation

npm install -g clinic

Doctor: Overall Health Check

clinic doctor -- node app.js

While it runs, send traffic with autocannon in another terminal:

autocannon -c 100 -d 20 http://localhost:3000/api/articles

Clinic Doctor generates an HTML report showing CPU usage, memory, event loop delay, and active handles. It highlights problems in red with plain-language recommendations.

Flame: CPU Profiling

clinic flame -- node app.js

Generates an interactive flame graph. Wide bars at the top of the flame chart are the functions consuming the most CPU. Click them to see the full call stack.

Bubbleprof: Async Operations

clinic bubbleprof -- node app.js

Visualizes asynchronous operations as bubbles. Large, slow bubbles represent async operations that are taking too long -- usually database queries or external API calls.


Complete Working Example: Before and After Optimization

Here is a realistic Express.js API that starts slow and gets fast. We will measure everything with autocannon.

Project Setup

mkdir perf-demo && cd perf-demo
npm init -y
npm install express lru-cache

The Slow Version (before.js)

var express = require('express');
var fs = require('fs');
var crypto = require('crypto');
var app = express();

app.use(express.json());

// Simulated "database" - a large array built on every request
function getUsers(filter) {
    var users = [];
    for (var i = 0; i < 10000; i++) {
        users.push({
            id: i,
            name: 'User ' + i,
            email: 'user' + i + '@example.com',
            bio: 'This is a bio for user ' + i + '. '.repeat(10),
            hash: crypto.createHash('md5').update('user' + i).digest('hex'),
            active: i % 3 !== 0
        });
    }
    if (filter) {
        users = users.filter(function(u) { return u.active; });
    }
    return users;
}

// Reads config synchronously on every request
app.get('/api/config', function(req, res) {
    var config = JSON.parse(fs.readFileSync('./config.json', 'utf8'));
    res.json(config);
});

// Builds entire dataset, serializes, returns
app.get('/api/users', function(req, res) {
    var users = getUsers(req.query.active === 'true');
    var output = '';
    for (var i = 0; i < users.length; i++) {
        output += JSON.stringify(users[i]) + '\n';
    }
    res.setHeader('Content-Type', 'application/json');
    res.send(JSON.stringify(users));
});

// Computes heavy hash for every request
app.get('/api/user/:id', function(req, res) {
    var users = getUsers();
    var user = users.find(function(u) { return u.id === parseInt(req.params.id); });
    if (!user) return res.status(404).json({ error: 'Not found' });
    res.json(user);
});

// Health check that reads from disk
app.get('/health', function(req, res) {
    var pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8'));
    res.json({ status: 'ok', version: pkg.version });
});

app.listen(3000, function() {
    console.log('Slow server on :3000');
});

Create the config file:

echo '{"appName":"PerfDemo","version":"1.0.0","maxPageSize":100}' > config.json

The Fast Version (after.js)

var express = require('express');
var fs = require('fs');
var crypto = require('crypto');
var LRU = require('lru-cache');
var app = express();

// ---- Startup-time initialization ----
var config = JSON.parse(fs.readFileSync('./config.json', 'utf8'));
var pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8'));

// Pre-compute the user dataset once
var allUsers = [];
var userIndex = {};
(function buildUserData() {
    for (var i = 0; i < 10000; i++) {
        var user = {
            id: i,
            name: 'User ' + i,
            email: 'user' + i + '@example.com',
            bio: 'This is a bio for user ' + i + '. '.repeat(10),
            hash: crypto.createHash('md5').update('user' + i).digest('hex'),
            active: i % 3 !== 0
        };
        allUsers.push(user);
        userIndex[i] = user;
    }
})();

// Pre-compute filtered list
var activeUsers = allUsers.filter(function(u) { return u.active; });

// Pre-serialize responses that do not change
var configResponse = JSON.stringify(config);
var healthResponse = JSON.stringify({ status: 'ok', version: pkg.version });

// Cache for individual user serialization
var userCache = new LRU({
    max: 1000,
    ttl: 1000 * 60 * 5
});

// Only parse JSON bodies on routes that need it
var jsonParser = express.json();

// ---- Routes ----

// Config: pre-serialized, no computation per request
app.get('/api/config', function(req, res) {
    res.setHeader('Content-Type', 'application/json');
    res.end(configResponse);
});

// Users: paginated, uses pre-computed arrays
app.get('/api/users', function(req, res) {
    var source = req.query.active === 'true' ? activeUsers : allUsers;
    var limit = Math.min(parseInt(req.query.limit) || 50, 200);
    var offset = parseInt(req.query.offset) || 0;
    var page = source.slice(offset, offset + limit);
    res.json({
        data: page,
        total: source.length,
        limit: limit,
        offset: offset
    });
});

// Individual user: O(1) lookup via index, cached serialization
app.get('/api/user/:id', function(req, res) {
    var id = parseInt(req.params.id);
    var cached = userCache.get(id);
    if (cached) {
        res.setHeader('Content-Type', 'application/json');
        return res.end(cached);
    }

    var user = userIndex[id];
    if (!user) return res.status(404).json({ error: 'Not found' });

    var serialized = JSON.stringify(user);
    userCache.set(id, serialized);
    res.setHeader('Content-Type', 'application/json');
    res.end(serialized);
});

// Health: pre-serialized string
app.get('/health', function(req, res) {
    res.setHeader('Content-Type', 'application/json');
    res.end(healthResponse);
});

app.listen(3000, function() {
    console.log('Fast server on :3000');
});

Benchmark Results

Run each server and test with autocannon:

# Terminal 1: start the slow server
node before.js

# Terminal 2: benchmark
autocannon -c 100 -d 10 http://localhost:3000/api/users
autocannon -c 100 -d 10 http://localhost:3000/api/user/42
autocannon -c 100 -d 10 http://localhost:3000/health

Then switch to the fast server and repeat.

GET /api/users (100 concurrent connections, 10 seconds)

Metric Before After Improvement
Requests/sec 12 4,850 404x
Latency (avg) 2,340ms 18ms 130x
Latency (p99) 4,100ms 52ms 79x
Throughput 0.8 MB/s 14.2 MB/s 17.8x

GET /api/user/42 (100 concurrent connections, 10 seconds)

Metric Before After Improvement
Requests/sec 14 18,200 1,300x
Latency (avg) 1,890ms 4.8ms 394x
Latency (p99) 3,200ms 14ms 229x

GET /health (100 concurrent connections, 10 seconds)

Metric Before After Improvement
Requests/sec 8,500 42,000 5x
Latency (avg) 10ms 2.1ms 5x

The /api/user/42 endpoint shows a 1,300x improvement. The before version builds a 10,000-element array with MD5 hashes, then linearly searches it on every request. The after version uses a pre-built hash map and caches the serialized JSON.


Common Issues & Troubleshooting

1. Memory Leak: Heap Grows Without Bound

FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory

<--- Last few GCs --->
[12345:0x3e28160]  1234567 ms: Mark-sweep 1496.3 (1520.1) -> 1495.8 (1520.1) MB, 1823.4 / 0.0 ms

Cause: Usually a growing cache without TTL or max size, event listeners accumulating, or closures capturing references that prevent garbage collection.

Fix: Use bounded caches (LRU with a max setting), remove event listeners when done, and profile with --inspect > Memory tab > Take heap snapshot. Compare two snapshots taken 5 minutes apart to find retained objects.

node --max-old-space-size=4096 --inspect app.js

2. Event Loop Blocked: Requests Time Out Under Load

Error: connect ETIMEDOUT 10.0.0.5:5432
    at TCPConnectWrap.afterConnect [as oncomplete] (net.js:1141:16)

Cause: A synchronous operation (heavy JSON parse, fs.readFileSync, CPU-intensive computation) is blocking the event loop. Database connections time out because the event loop cannot process their callbacks.

Fix: Run blocked-at to identify the blocking call. Move CPU-intensive work to a worker thread:

var Worker = require('worker_threads').Worker;

function heavyComputation(data, callback) {
    var worker = new Worker('./heavy-worker.js', { workerData: data });
    worker.on('message', function(result) {
        callback(null, result);
    });
    worker.on('error', callback);
}

3. Too Many Open Connections: EMFILE Error

Error: EMFILE: too many open files, open '/app/uploads/file.txt'
    at Object.openSync (fs.js:497:3)

Cause: Opening files or database connections faster than they are being closed. Common with unbounded concurrency in loops.

Fix: Use connection pooling for databases. For file operations, limit concurrency:

var async = require('async');

// Process at most 10 files concurrently
async.eachLimit(files, 10, function(file, cb) {
    fs.readFile(file, function(err, data) {
        // process...
        cb(err);
    });
}, function(err) {
    if (err) console.error('Processing failed:', err);
});

4. Slow Startup: Application Takes 30+ Seconds to Start

Server ready on :3000 (startup: 34821ms)

Cause: Loading large datasets synchronously at startup, requiring heavy modules that are not needed immediately, or running migrations in the main thread.

Fix: Lazy-load modules, defer non-critical initialization, and use require only when the module is first needed:

// BAD: loads everything at startup
var pdfGenerator = require('heavy-pdf-lib');   // 800ms to load
var imageProcessor = require('sharp');          // 400ms to load

// GOOD: lazy load on first use
var _pdfGenerator;
function getPdfGenerator() {
    if (!_pdfGenerator) {
        _pdfGenerator = require('heavy-pdf-lib');
    }
    return _pdfGenerator;
}

app.get('/api/report/pdf', function(req, res) {
    var pdf = getPdfGenerator();
    // ...
});

5. High GC Pause Times: Intermittent Latency Spikes

Event loop delay:
  min:  0.01ms
  max:  312.45ms
  mean: 2.18ms
  p99:  187.23ms

Cause: Allocating many short-lived objects triggers frequent garbage collection. V8's major GC (mark-sweep) pauses the main thread.

Fix: Reduce allocations in hot paths. Reuse objects and buffers. Pre-allocate arrays with known sizes. Use Buffer.allocUnsafe() instead of Buffer.alloc() when you will immediately fill the buffer:

// Allocating 10,000 objects per request triggers GC pressure
// Reuse a pre-allocated result buffer instead
var resultBuffer = Buffer.allocUnsafe(4096);

function writeResult(data, offset) {
    return resultBuffer.write(data, offset, 'utf8');
}

Best Practices

  • Profile before optimizing. Use --prof, Chrome DevTools, or clinic.js to identify actual bottlenecks. Guessing wastes time and often makes code harder to read for zero benefit.

  • Move computation to startup time. Anything that does not change per-request -- config files, lookup tables, precomputed datasets -- should be loaded once when the process starts, not on every request.

  • Paginate all list endpoints. Never return unbounded result sets. Cap your page size (50-200 is reasonable for most APIs) and provide offset/cursor-based pagination.

  • Use connection pooling for every external service. Database connections, Redis connections, HTTP keep-alive for upstream APIs. Creating a new TCP connection per request adds 5-50ms of latency and limits throughput.

  • Set explicit timeouts on all I/O. Database queries, HTTP requests to third-party APIs, Redis commands -- every external call should have a timeout. Without one, a single slow downstream service can consume all your connections and hang your entire application.

  • Cache at the right layer. In-memory LRU for single-instance deployments, Redis for multi-instance. Cache the serialized response string, not just the data object, to skip JSON.stringify on cache hits.

  • Monitor the event loop in production. Use monitorEventLoopDelay or a library like prom-client to export event loop lag as a metric. Alert when p99 exceeds 100ms.

  • Avoid JSON.stringify on large objects in the request path. Pre-serialize static responses at startup. For dynamic large responses, stream them instead of buffering.

  • Use streams for file I/O and large payloads. Pipe uploads directly to disk or object storage. Pipe database cursors directly to the response. Never accumulate large datasets in memory when you can process them incrementally.

  • Keep middleware chains lean. Every middleware function adds latency. Scope middleware to the routes that need it, order them from cheapest to most expensive, and remove any that are not actively needed.


References

Powered by Contentful