Node.js Performance: Clustering, Caching, and Profiling

Node.js Performance: Clustering, Caching, and Profiling

이 글의 핵심

Practical Node.js performance tuning: cluster module and PM2, Redis and LRU caches, DB indexes and lean queries, async parallelism, memory and streams, gzip, and worker threads for CPU-heavy work.

Introduction

Why performance matters

Reasons to optimize:

  • User experience: Lower latency and faster responses
  • Cost: Better utilization of server resources
  • Scale: Handle more concurrent users
  • SEO: Page speed affects search rankings

Principles:

  1. Measure first: Do not guess—profile and measure
  2. Find bottlenecks: Improve the slowest parts first
  3. Iterate: Change one thing at a time
  4. Trade-offs: Balance performance vs readability and maintainability

1. Clustering

Cluster module

// cluster.js
const cluster = require('cluster');
const http = require('http');
const os = require('os');

const numCPUs = os.cpus().length;

if (cluster.isMaster) {
    console.log(`Master process ${process.pid} running`);
    
    // Fork worker processes
    for (let i = 0; i < numCPUs; i++) {
        cluster.fork();
    }
    
    // Restart workers on exit
    cluster.on('exit', (worker, code, signal) => {
        console.log(`Worker ${worker.process.pid} exited`);
        console.log('Starting new worker...');
        cluster.fork();
    });
    
} else {
    // Workers run the HTTP server
    const server = http.createServer((req, res) => {
        res.writeHead(200);
        res.end(`Handled by worker ${process.pid}\n`);
    });
    
    server.listen(3000, () => {
        console.log(`Worker ${process.pid} started`);
    });
}

Run:

node cluster.js
# Master process 12345 running
# Worker 12346 started
# ...

PM2 cluster mode

# One process per CPU core
pm2 start app.js -i max

# Fixed count
pm2 start app.js -i 4

# Zero-downtime reload
pm2 reload app

2. Caching

In-memory cache

// Simple TTL cache
const cache = new Map();

function getCachedData(key, fetchFn, ttl = 60000) {
    const cached = cache.get(key);
    
    if (cached && Date.now() < cached.expiresAt) {
        console.log('Cache hit');
        return cached.data;
    }
    
    console.log('Cache miss');
    const data = fetchFn();
    
    cache.set(key, {
        data,
        expiresAt: Date.now() + ttl
    });
    
    return data;
}

// Usage
app.get('/api/users', async (req, res) => {
    const users = getCachedData('users', async () => {
        return await User.find();
    }, 60000);  // 1 minute
    
    res.json({ users });
});

Redis caching

npm install redis
// cache.js
const redis = require('redis');

const client = redis.createClient({
    host: 'localhost',
    port: 6379
});

client.on('error', (err) => {
    console.error('Redis error:', err);
});

client.connect();

async function setCache(key, value, ttl = 3600) {
    await client.setEx(key, ttl, JSON.stringify(value));
}

async function getCache(key) {
    const value = await client.get(key);
    return value ? JSON.parse(value) : null;
}

async function deleteCache(key) {
    await client.del(key);
}

module.exports = { setCache, getCache, deleteCache };
// Usage
const { getCache, setCache } = require('./cache');

app.get('/api/users/:id', async (req, res) => {
    const { id } = req.params;
    const cacheKey = `user:${id}`;
    
    let user = await getCache(cacheKey);
    
    if (user) {
        console.log('Cache hit');
        return res.json({ user, cached: true });
    }
    
    user = await User.findById(id);
    
    if (!user) {
        return res.status(404).json({ error: 'User not found' });
    }
    
    await setCache(cacheKey, user, 3600);
    
    res.json({ user, cached: false });
});

Cache invalidation

app.put('/api/users/:id', async (req, res) => {
    const { id } = req.params;
    
    const user = await User.findByIdAndUpdate(id, req.body, { new: true });
    
    await deleteCache(`user:${id}`);
    
    res.json({ user });
});

3. Database optimization

Indexes

// MongoDB
userSchema.index({ email: 1 });
userSchema.index({ name: 1, age: -1 });
userSchema.index({ email: 1 }, { unique: true });

const indexes = await User.collection.getIndexes();
console.log(indexes);

Query optimization

// Slow: N+1
const posts = await Post.find();
for (const post of posts) {
    const author = await User.findById(post.author);
}

// Prefer populate
const posts = await Post.find().populate('author');

// Project only needed fields
const posts = await Post.find()
    .select('title content author')
    .populate('author', 'name email');

// lean(): plain objects, faster reads
const posts = await Post.find().lean();

Connection pool

mongoose.connect('mongodb://localhost:27017/mydb', {
    maxPoolSize: 10,
    minPoolSize: 2
});

const pool = new Pool({
    max: 20,
    min: 5,
    idleTimeoutMillis: 30000
});

4. Async optimization

Parallelism

// Sequential (slow)
async function sequential() {
    const users = await User.find();
    const posts = await Post.find();
    const comments = await Comment.find();
    return { users, posts, comments };
}
// Total time: T1 + T2 + T3

// Parallel (fast)
async function parallel() {
    const [users, posts, comments] = await Promise.all([
        User.find(),
        Post.find(),
        Comment.find()
    ]);
    return { users, posts, comments };
}
// Total time: max(T1, T2, T3)

Concurrency limit

const pLimit = require('p-limit');

async function processFiles(files) {
    const limit = pLimit(5);
    const results = await Promise.all(
        files.map(file => limit(() => processFile(file)))
    );
    return results;
}

5. Memory management

Inspect memory

function logMemoryUsage() {
    const used = process.memoryUsage();
    console.log({
        rss: `${Math.round(used.rss / 1024 / 1024)} MB`,
        heapTotal: `${Math.round(used.heapTotal / 1024 / 1024)} MB`,
        heapUsed: `${Math.round(used.heapUsed / 1024 / 1024)} MB`,
        external: `${Math.round(used.external / 1024 / 1024)} MB`
    });
}

setInterval(logMemoryUsage, 60000);

Avoid unbounded caches

// Bad: unbounded Map growth
const cache = new Map();
app.get('/api/data/:id', async (req, res) => {
    const data = await fetchData(req.params.id);
    cache.set(req.params.id, data);
    res.json(data);
});

// Better: LRU
const LRU = require('lru-cache');
const cache = new LRU({
    max: 500,
    maxAge: 1000 * 60 * 60
});

app.get('/api/data/:id', async (req, res) => {
    let data = cache.get(req.params.id);
    if (!data) {
        data = await fetchData(req.params.id);
        cache.set(req.params.id, data);
    }
    res.json(data);
});

Streams for large payloads

// Bad: load entire file into memory
app.get('/download', async (req, res) => {
    const data = await fs.promises.readFile('large-file.pdf');
    res.send(data);
});

// Good: stream
app.get('/download', (req, res) => {
    fs.createReadStream('large-file.pdf').pipe(res);
});

6. Profiling

Built-in CPU profiler

node --prof app.js
node --prof-process isolate-0x*.log > processed.txt

Chrome DevTools

node --inspect app.js
node --inspect-brk app.js

Open chrome://inspect and attach for CPU/memory profiling.

clinic.js

npm install -g clinic
clinic doctor -- node app.js
clinic bubbleprof -- node app.js
clinic heapprofiler -- node app.js

7. Benchmarking

Apache Bench

sudo apt install apache2-utils
ab -n 1000 -c 10 http://localhost:3000/

autocannon

npm install -g autocannon
autocannon -c 10 -d 10 http://localhost:3000/

Programmatic benchmark

const autocannon = require('autocannon');

async function runBenchmark() {
    const result = await autocannon({
        url: 'http://localhost:3000',
        connections: 10,
        duration: 10,
        pipelining: 1
    });
    console.log('Req/sec:', result.requests.mean);
    console.log('Latency ms:', result.latency.mean);
    console.log('Throughput:', result.throughput.mean, 'bytes/sec');
}

runBenchmark();

8. Practical examples

Example 1: API response caching middleware

const express = require('express');
const redis = require('redis');

const app = express();
const client = redis.createClient();
await client.connect();

function cacheMiddleware(ttl = 3600) {
    return async (req, res, next) => {
        const key = `cache:${req.originalUrl}`;
        try {
            const cached = await client.get(key);
            if (cached) {
                console.log('Cache hit');
                return res.json(JSON.parse(cached));
            }
            const originalJson = res.json.bind(res);
            res.json = (data) => {
                client.setEx(key, ttl, JSON.stringify(data));
                return originalJson(data);
            };
            next();
        } catch (err) {
            next();
        }
    };
}

app.get('/api/users', cacheMiddleware(60), async (req, res) => {
    const users = await User.find();
    res.json({ users });
});

Example 2: Optimized posts query

async function getPostsWithAuthors() {
    const posts = await Post.find();
    for (const post of posts) {
        post.author = await User.findById(post.author);
    }
    return posts;
}

async function getPostsWithAuthorsOptimized() {
    return Post.find()
        .populate('author', 'name email')
        .select('title content author createdAt')
        .lean()
        .limit(20);
}

Example 3: Image optimization with sharp

npm install sharp
const sharp = require('sharp');
const fs = require('fs');

async function optimizeImage(inputPath, outputPath) {
    await sharp(inputPath)
        .resize(800, 600, { fit: 'inside', withoutEnlargement: true })
        .jpeg({ quality: 80 })
        .toFile(outputPath);
    const inputSize = fs.statSync(inputPath).size;
    const outputSize = fs.statSync(outputPath).size;
    console.log(`Size reduction: ${((1 - outputSize / inputSize) * 100).toFixed(2)}%`);
}

const multer = require('multer');
const upload = multer({ dest: 'uploads/' });

app.post('/upload', upload.single('image'), async (req, res) => {
    const inputPath = req.file.path;
    const outputPath = `optimized/${req.file.filename}.jpg`;
    await optimizeImage(inputPath, outputPath);
    res.json({ path: outputPath });
});

9. Compression

gzip

npm install compression
const compression = require('compression');
app.use(compression());

app.use(compression({
    filter: (req, res) => {
        if (req.headers['x-no-compression']) return false;
        return compression.filter(req, res);
    },
    level: 6
}));

Typical savings: HTML 70–90%, JSON 60–80%, CSS/JS 50–70%.


10. Common pitfalls

Event loop blocking

// Bad: CPU-heavy work on main thread
app.get('/heavy', (req, res) => {
    let sum = 0;
    for (let i = 0; i < 1e9; i++) sum += i;
    res.json({ sum });
});

// Better: worker thread
const { Worker } = require('worker_threads');

app.get('/heavy', (req, res) => {
    const worker = new Worker('./heavy-task.js');
    worker.on('message', (result) => res.json({ sum: result }));
    worker.on('error', (err) => res.status(500).json({ error: err.message }));
});
// heavy-task.js
const { parentPort } = require('worker_threads');
let sum = 0;
for (let i = 0; i < 1e9; i++) sum += i;
parentPort.postMessage(sum);

Memory leaks

Causes: Global accumulators, listeners never removed, unbounded caches.

const EventEmitter = require('events');
const emitter = new EventEmitter();

// Bad: listener added every request
app.get('/api/data', (req, res) => {
    emitter.on('data', (data) => res.json(data));
});

// Better: remove after use
app.get('/api/data', (req, res) => {
    const handler = (data) => {
        res.json(data);
        emitter.off('data', handler);
    };
    emitter.on('data', handler);
});

11. Practical tips

Request timing

app.use((req, res, next) => {
    const start = Date.now();
    res.on('finish', () => {
        const duration = Date.now() - start;
        if (duration > 1000) {
            console.warn(`Slow: ${req.method} ${req.url} ${duration}ms`);
        }
    });
    next();
});

Centralized async errors

function asyncHandler(fn) {
    return (req, res, next) => {
        Promise.resolve(fn(req, res, next)).catch(next);
    };
}

app.get('/api/users', asyncHandler(async (req, res) => {
    const users = await User.find();
    res.json({ users });
}));

app.use((err, req, res, next) => {
    logger.error(err.message, { stack: err.stack });
    res.status(500).json({ error: 'Internal Server Error' });
});

Keep-Alive

const http = require('http');
const server = http.createServer((req, res) => res.end('Hello'));
server.keepAliveTimeout = 65000;
server.headersTimeout = 66000;
server.listen(3000);

Summary

Takeaways

  1. Clustering: Cluster module or PM2 -i for multi-core
  2. Caching: Redis, in-memory TTL, invalidate on writes
  3. Database: Indexes, avoid N+1, connection pools, lean() where appropriate
  4. Async: Promise.all, limit concurrency with p-limit
  5. Memory: Streams, LRU caches, monitor RSS/heap
  6. Compression: compression middleware, optimize images

Optimization priority

  1. Database queries (often the largest bottleneck)
  2. Caching (high impact, moderate effort)
  3. Async parallelism
  4. Compression (network)
  5. Clustering (throughput)

Tools

ToolUse
--profCPU samples
Chrome DevToolsHeap and CPU
clinic.jsHolistic diagnosis
autocannonHTTP load tests
PM2Process metrics

Next steps

  • Deeper Node.js security hardening
  • Microservices patterns

Resources


  • Node.js Getting Started
  • Node.js Modules
  • Node.js Async Programming