Performance Optimization in Node.js: Real-World Techniques

Performance Optimization in Node.js: Real-World Techniques

BySanjay Goraniya
2 min read
Share:

Performance Optimization in Node.js: Real-World Techniques

Node.js is fast, but it's not magic. To build truly high-performance applications, you need to understand how Node.js works and apply optimization techniques strategically. After optimizing Node.js applications that serve millions of requests daily, I've learned what actually works.

Understanding Node.js Performance

The Event Loop

Node.js is single-threaded but non-blocking. Understanding the event loop is crucial:

Code
// Blocking operation (bad)
const data = fs.readFileSync('large-file.txt'); // Blocks event loop

// Non-blocking operation (good)
fs.readFile('large-file.txt', (err, data) => {
  // Callback executed when ready
});

CPU vs I/O Bound Operations

  • I/O bound: Database queries, file operations, network requests
  • CPU bound: Image processing, encryption, data transformation

Node.js excels at I/O bound operations. For CPU-bound tasks, use worker threads.

Async Patterns

Avoid Callback Hell

Code
// Bad: Callback hell
getUser(id, (user) => {
  getOrders(user.id, (orders) => {
    getItems(orders[0].id, (items) => {
      // Nested callbacks
    });
  });
});

// Good: Promises
getUser(id)
  .then(user => getOrders(user.id))
  .then(orders => getItems(orders[0].id))
  .then(items => {
    // Clean and readable
  });

// Better: Async/await
const user = await getUser(id);
const orders = await getOrders(user.id);
const items = await getItems(orders[0].id);

Parallel Execution

Code
// Bad: Sequential (slow)
const user = await getUser(id);
const orders = await getOrders(user.id);
const settings = await getSettings(user.id);

// Good: Parallel (fast)
const [user, orders, settings] = await Promise.all([
  getUser(id),
  getOrders(user.id),
  getSettings(user.id)
]);

Batch Operations

Code
// Bad: One query per item
for (const id of userIds) {
  const user = await getUser(id);
  users.push(user);
}

// Good: Batch query
const users = await getUsersByIds(userIds);

Memory Management

Avoid Memory Leaks

Code
// Bad: Memory leak
const cache = new Map();
setInterval(() => {
  const data = fetchData();
  cache.set(Date.now(), data); // Never cleared!
}, 1000);

// Good: Bounded cache
class BoundedCache {
  constructor(maxSize = 1000) {
    this.cache = new Map();
    this.maxSize = maxSize;
  }

  set(key, value) {
    if (this.cache.size >= this.maxSize) {
      const firstKey = this.cache.keys().next().value;
      this.cache.delete(firstKey);
    }
    this.cache.set(key, value);
  }
}

Stream Large Data

Code
// Bad: Load entire file into memory
const data = fs.readFileSync('huge-file.json');
const parsed = JSON.parse(data);

// Good: Stream processing
const stream = fs.createReadStream('huge-file.json');
const parser = JSONStream.parse('*');

stream.pipe(parser)
  .on('data', (item) => {
    processItem(item);
  });

Database Optimization

Connection Pooling

Code
// Bad: New connection per request
app.get('/users', async (req, res) => {
  const client = await pool.connect();
  const result = await client.query('SELECT * FROM users');
  client.release();
  res.json(result.rows);
});

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

app.get('/users', async (req, res) => {
  const result = await pool.query('SELECT * FROM users');
  res.json(result.rows);
});

Query Optimization

Code
// Bad: N+1 queries
const users = await db.query('SELECT * FROM users');
for (const user of users.rows) {
  user.orders = await db.query(
    'SELECT * FROM orders WHERE user_id = $1',
    [user.id]
  );
}

// Good: Single query with JOIN
const result = await db.query(`
  SELECT 
    u.*,
    json_agg(o.*) as orders
  FROM users u
  LEFT JOIN orders o ON o.user_id = u.id
  GROUP BY u.id
`);

Caching Strategies

In-Memory Caching

Code
const NodeCache = require('node-cache');
const cache = new NodeCache({ stdTTL: 3600 });

async function getUser(id) {
  const cacheKey = `user:${id}`;
  
  // Check cache
  const cached = cache.get(cacheKey);
  if (cached) {
    return cached;
  }
  
  // Cache miss - fetch from database
  const user = await db.query('SELECT * FROM users WHERE id = $1', [id]);
  
  // Cache for 1 hour
  cache.set(cacheKey, user.rows[0]);
  
  return user.rows[0];
}

Redis Caching

Code
const redis = require('redis');
const client = redis.createClient();

async function getProduct(id) {
  const cacheKey = `product:${id}`;
  
  // Try cache
  const cached = await client.get(cacheKey);
  if (cached) {
    return JSON.parse(cached);
  }
  
  // Cache miss
  const product = await db.query('SELECT * FROM products WHERE id = $1', [id]);
  
  // Cache for 1 hour
  await client.setex(cacheKey, 3600, JSON.stringify(product.rows[0]));
  
  return product.rows[0];
}

Worker Threads for CPU-Intensive Tasks

Code
const { Worker, isMainThread, parentPort } = require('worker_threads');

if (isMainThread) {
  // Main thread
  function processImage(imagePath) {
    return new Promise((resolve, reject) => {
      const worker = new Worker(__filename);
      worker.postMessage(imagePath);
      worker.on('message', resolve);
      worker.on('error', reject);
    });
  }
} else {
  // Worker thread
  parentPort.on('message', (imagePath) => {
    // CPU-intensive image processing
    const processed = processImageSync(imagePath);
    parentPort.postMessage(processed);
  });
}

Clustering for Multi-Core Utilization

Code
const cluster = require('cluster');
const os = require('os');

if (cluster.isMaster) {
  const numCPUs = os.cpus().length;
  
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }
  
  cluster.on('exit', (worker) => {
    console.log(`Worker ${worker.process.pid} died`);
    cluster.fork();
  });
} else {
  // Worker process
  const express = require('express');
  const app = express();
  
  app.get('/', (req, res) => {
    res.json({ pid: process.pid });
  });
  
  app.listen(3000);
}

Monitoring and Profiling

Use Performance Hooks

Code
const { performance, PerformanceObserver } = require('perf_hooks');

const obs = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log(`${entry.name}: ${entry.duration}ms`);
  }
});

obs.observe({ entryTypes: ['measure'] });

performance.mark('start');
// Your code here
performance.mark('end');
performance.measure('operation', 'start', 'end');

Memory Profiling

Code
// Check memory usage
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`
});

Common Performance Pitfalls

1. Synchronous Operations

Code
// Bad: Blocks event loop
const data = fs.readFileSync('file.txt');

// Good: Non-blocking
const data = await fs.promises.readFile('file.txt');

2. Unnecessary JSON Parsing

Code
// Bad: Parse multiple times
const data = JSON.parse(jsonString);
const processed = process(data);
const result = JSON.stringify(processed);

// Good: Parse once, work with objects
const data = JSON.parse(jsonString);
const processed = process(data);
// Only stringify when needed

3. Inefficient Loops

Code
// Bad: O(n²)
for (const item of items) {
  const found = items.find(i => i.id === item.parentId);
}

// Good: O(n)
const lookup = new Map(items.map(i => [i.id, i]));
for (const item of items) {
  const found = lookup.get(item.parentId);
}

Real-World Example

Challenge: API handling 10,000 requests/minute, response time 2+ seconds.

Optimizations Applied:

  1. Added connection pooling - Reduced DB connection overhead
  2. Implemented Redis caching - 80% cache hit rate
  3. Optimized queries - Removed N+1 queries
  4. Added request batching - Reduced database calls
  5. Used clustering - Utilized all CPU cores

Result:

  • Response time: 2s → 50ms
  • Throughput: 10,000 → 50,000 requests/minute
  • CPU usage: More efficient

Best Practices Summary

  1. Profile first - Measure before optimizing
  2. Use async/await - Avoid blocking operations
  3. Cache intelligently - Cache what changes infrequently
  4. Optimize database queries - Avoid N+1 problems
  5. Use worker threads - For CPU-intensive tasks
  6. Monitor continuously - Track performance metrics
  7. Load test - Verify optimizations work under load

Conclusion

Node.js performance optimization is about understanding the runtime and applying the right techniques. The key is to:

  • Measure before optimizing
  • Focus on bottlenecks
  • Use the right tool for the job
  • Monitor continuously

Remember: Premature optimization is the root of all evil, but ignoring performance is the root of all production incidents. Find the balance.

What Node.js performance challenges have you faced? What techniques have worked best for you?

Share:

Related Posts