Aashima Arora

Building Scalable Node.js Applications

Learn the best practices and architectural patterns for building scalable Node.js applications.

February 15, 2024
Aashima Arora
5 min read

Building Scalable Node.js Applications

Building scalable applications with Node.js requires understanding of architectural patterns, performance optimization techniques, and best practices. This guide will walk you through the essential concepts and tools needed to create robust, scalable Node.js applications.

Understanding Scalability in Node.js

Scalability in Node.js can be achieved through two main approaches:

  1. Vertical Scaling: Increasing the resources of a single server
  2. Horizontal Scaling: Distributing the load across multiple servers

Node.js excels at horizontal scaling due to its single-threaded, event-driven architecture.

Core Architectural Patterns

Microservices Architecture

// user-service.js
const express = require('express');
const app = express();

app.get('/users/:id', async (req, res) => {
  try {
    const user = await userService.findById(req.params.id);
    res.json(user);
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

// Service communication
const communicateWithOrderService = async (userId) => {
  const response = await axios.get(`http://order-service/orders/${userId}`);
  return response.data;
};

Event-Driven Architecture

// eventBus.js
const EventEmitter = require('events');
class EventBus extends EventEmitter {}

const eventBus = new EventBus();

// userService.js
eventBus.on('user:created', async (userData) => {
  await sendWelcomeEmail(userData.email);
  await createUserProfile(userData.id);
});

// Creating a new user
const createUser = async (userData) => {
  const user = await User.create(userData);
  eventBus.emit('user:created', user);
  return user;
};

Performance Optimization

Database Optimization

// Connection pooling with PostgreSQL
const { Pool } = require('pg');

const pool = new Pool({
  user: 'username',
  host: 'localhost',
  database: 'mydb',
  password: 'password',
  port: 5432,
  max: 20, // Maximum number of connections
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 2000,
});

// Using connection pool
const getUsers = async () => {
  const client = await pool.connect();
  try {
    const result = await client.query('SELECT * FROM users');
    return result.rows;
  } finally {
    client.release();
  }
};

Caching Strategies

// Redis caching implementation
const redis = require('redis');
const client = redis.createClient();

const getCachedData = async (key) => {
  try {
    const cachedData = await client.get(key);
    if (cachedData) {
      return JSON.parse(cachedData);
    }
    return null;
  } catch (error) {
    console.error('Cache error:', error);
    return null;
  }
};

const setCachedData = async (key, data, ttl = 3600) => {
  try {
    await client.setex(key, ttl, JSON.stringify(data));
  } catch (error) {
    console.error('Cache set error:', error);
  }
};

// Usage in API endpoint
app.get('/products/:id', async (req, res) => {
  const cacheKey = `product:${req.params.id}`;

  let product = await getCachedData(cacheKey);

  if (!product) {
    product = await productService.findById(req.params.id);
    await setCachedData(cacheKey, product, 1800); // 30 minutes
  }

  res.json(product);
});

Error Handling and Resilience

Global Error Handling

// error handling middleware
const errorHandler = (err, req, res, next) => {
  console.error(err.stack);

  if (err.type === 'validation') {
    return res.status(400).json({
      error: 'Validation Error',
      details: err.details
    });
  }

  if (err.type === 'database') {
    return res.status(503).json({
      error: 'Service Unavailable',
      message: 'Database operation failed'
    });
  }

  res.status(500).json({
    error: 'Internal Server Error',
    message: process.env.NODE_ENV === 'production'
      ? 'Something went wrong'
      : err.message
  });
};

app.use(errorHandler);

Circuit Breaker Pattern

const CircuitBreaker = require('opossum');

const options = {
  timeout: 3000, // If function takes longer than 3 seconds, trigger a failure
  errorThresholdPercentage: 50, // When 50% of requests fail, trip the circuit
  resetTimeout: 30000 // After 30 seconds, try again
};

const breaker = new CircuitBreaker(callExternalService, options);

breaker.on('open', () => console.log('Circuit breaker is OPEN'));
breaker.on('halfOpen', () => console.log('Circuit breaker is HALF OPEN'));
breaker.on('close', () => console.log('Circuit breaker is CLOSED'));

app.get('/external-data', async (req, res) => {
  try {
    const data = await breaker.fire();
    res.json(data);
  } catch (error) {
    res.status(503).json({ error: 'Service temporarily unavailable' });
  }
});

Monitoring and Observability

Structured Logging

const winston = require('winston');

const logger = winston.createLogger({
  level: 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.errors({ stack: true }),
    winston.format.json()
  ),
  defaultMeta: { service: 'user-service' },
  transports: [
    new winston.transports.File({ filename: 'error.log', level: 'error' }),
    new winston.transports.File({ filename: 'combined.log' }),
    new winston.transports.Console({
      format: winston.format.simple()
    })
  ]
});

// Custom middleware for request logging
const requestLogger = (req, res, next) => {
  const start = Date.now();

  res.on('finish', () => {
    const duration = Date.now() - start;
    logger.info('HTTP Request', {
      method: req.method,
      url: req.url,
      statusCode: res.statusCode,
      duration,
      userAgent: req.get('User-Agent'),
      ip: req.ip
    });
  });

  next();
};

app.use(requestLogger);

Health Checks

app.get('/health', async (req, res) => {
  const health = {
    status: 'OK',
    timestamp: new Date().toISOString(),
    uptime: process.uptime(),
    checks: {
      database: 'OK',
      redis: 'OK',
      memory: process.memoryUsage(),
      cpu: process.cpuUsage()
    }
  };

  try {
    // Check database connection
    await pool.query('SELECT 1');
  } catch (error) {
    health.checks.database = 'ERROR';
    health.status = 'ERROR';
  }

  try {
    // Check Redis connection
    await client.ping();
  } catch (error) {
    health.checks.redis = 'ERROR';
    health.status = 'ERROR';
  }

  const statusCode = health.status === 'OK' ? 200 : 503;
  res.status(statusCode).json(health);
});

Security Best Practices

// Rate limiting
const rateLimit = require('express-rate-limit');

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // Limit each IP to 100 requests per windowMs
  message: 'Too many requests from this IP'
});

app.use('/api/', limiter);

// Security headers
const helmet = require('helmet');
app.use(helmet());

// Input validation
const { body, validationResult } = require('express-validator');

const validateUser = [
  body('email').isEmail().normalizeEmail(),
  body('password').isLength({ min: 8 }).matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/),
  (req, res, next) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({ errors: errors.array() });
    }
    next();
  }
];

app.post('/users', validateUser, async (req, res) => {
  // Create user logic
});

Deployment and Scaling

Docker Configuration

# Dockerfile
FROM node:18-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production

COPY . .

EXPOSE 3000

USER node

CMD ["node", "server.js"]

PM2 for Process Management

// ecosystem.config.js
module.exports = {
  apps: [{
    name: 'my-app',
    script: './server.js',
    instances: 'max',
    exec_mode: 'cluster',
    env: {
      NODE_ENV: 'production'
    },
    error_file: './logs/err.log',
    out_file: './logs/out.log',
    log_file: './logs/combined.log',
    time: true
  }]
};

Conclusion

Building scalable Node.js applications requires careful consideration of architecture, performance, security, and monitoring. By implementing these patterns and best practices, you can create applications that handle growth gracefully and maintain high performance under load.

Remember that scalability is not just about handling more users—it's about building maintainable, resilient systems that can evolve with your business needs.

Related Posts

Feb 1, 20243 min read

From Announcer to Author: My Publishing Journey

Follow my journey from being an AIR announcer to becoming a published romance author, and discover the lessons I learned along the way.

Aashima Arora
Jan 15, 20244 min read

The Art of Writing Romance: Finding Your Voice

Discover the essential elements of writing compelling romance novels and developing your unique author voice.

Aashima Arora