Building Scalable APIs with Node.js and Express


Building scalable APIs is crucial for modern web applications. Node.js and Express provide an excellent foundation for creating robust, high-performance APIs. At Dev Intelligence, we’ve built numerous scalable APIs using these technologies, and we’re sharing our best practices.

Setting Up Your Express Application

Project Structure

Start with a well-organized project structure:

src/
├── controllers/
│   ├── userController.js
│   └── productController.js
├── middleware/
│   ├── auth.js
│   ├── validation.js
│   └── errorHandler.js
├── models/
│   ├── User.js
│   └── Product.js
├── routes/
│   ├── users.js
│   └── products.js
├── services/
│   ├── userService.js
│   └── emailService.js
├── utils/
│   ├── database.js
│   └── logger.js
└── app.js

Basic Express Setup

const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const compression = require('compression');

const app = express();

// Security middleware
app.use(helmet());
app.use(cors());

// Compression middleware
app.use(compression());

// Rate limiting
const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100 // limit each IP to 100 requests per windowMs
});
app.use(limiter);

// Body parsing middleware
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));

module.exports = app;

API Design Best Practices

RESTful API Design

Follow REST conventions for predictable, intuitive APIs:

// Good RESTful routes
GET    /api/users          // Get all users
GET    /api/users/:id      // Get specific user
POST   /api/users          // Create new user
PUT    /api/users/:id      // Update user
DELETE /api/users/:id      // Delete user

// Avoid non-RESTful patterns
GET    /api/getUsers       // Bad
POST   /api/deleteUser     // Bad

Consistent Response Format

Use a consistent response format across your API:

// Success response
{
  "success": true,
  "data": {
    "id": 1,
    "name": "John Doe",
    "email": "john@example.com"
  },
  "message": "User retrieved successfully"
}

// Error response
{
  "success": false,
  "error": {
    "code": "USER_NOT_FOUND",
    "message": "User with ID 1 not found"
  },
  "timestamp": "2024-01-15T10:30:00Z"
}

HTTP Status Codes

Use appropriate HTTP status codes:

// Success codes
200 OK           // Successful GET, PUT
201 Created      // Successful POST
204 No Content   // Successful DELETE

// Client error codes
400 Bad Request  // Invalid request data
401 Unauthorized // Authentication required
403 Forbidden    // Access denied
404 Not Found    // Resource not found
422 Unprocessable Entity // Validation errors

// Server error codes
500 Internal Server Error // Server error
503 Service Unavailable   // Service down

Middleware Implementation

Authentication Middleware

const jwt = require('jsonwebtoken');

const authenticateToken = (req, res, next) => {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1];

  if (!token) {
    return res.status(401).json({
      success: false,
      error: { code: 'TOKEN_REQUIRED', message: 'Access token required' }
    });
  }

  jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
    if (err) {
      return res.status(403).json({
        success: false,
        error: { code: 'INVALID_TOKEN', message: 'Invalid or expired token' }
      });
    }
    req.user = user;
    next();
  });
};

Validation Middleware

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

const validateUser = [
  body('name').trim().isLength({ min: 2 }).withMessage('Name must be at least 2 characters'),
  body('email').isEmail().normalizeEmail().withMessage('Valid email required'),
  body('password').isLength({ min: 8 }).withMessage('Password must be at least 8 characters'),
  
  (req, res, next) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(422).json({
        success: false,
        error: {
          code: 'VALIDATION_ERROR',
          message: 'Validation failed',
          details: errors.array()
        }
      });
    }
    next();
  }
];

Error Handling Middleware

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

  // Mongoose validation error
  if (err.name === 'ValidationError') {
    const errors = Object.values(err.errors).map(e => e.message);
    return res.status(422).json({
      success: false,
      error: {
        code: 'VALIDATION_ERROR',
        message: 'Validation failed',
        details: errors
      }
    });
  }

  // JWT errors
  if (err.name === 'JsonWebTokenError') {
    return res.status(401).json({
      success: false,
      error: { code: 'INVALID_TOKEN', message: 'Invalid token' }
    });
  }

  // Default error
  res.status(500).json({
    success: false,
    error: {
      code: 'INTERNAL_ERROR',
      message: 'Something went wrong'
    }
  });
};

Database Integration

Connection Pooling

const { Pool } = require('pg');

const pool = new Pool({
  user: process.env.DB_USER,
  host: process.env.DB_HOST,
  database: process.env.DB_NAME,
  password: process.env.DB_PASSWORD,
  port: process.env.DB_PORT,
  max: 20,
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 2000,
});

// Test connection
pool.on('connect', () => {
  console.log('Connected to database');
});

pool.on('error', (err) => {
  console.error('Database connection error:', err);
});

Model Layer

class UserModel {
  static async create(userData) {
    const { name, email, password } = userData;
    const query = `
      INSERT INTO users (name, email, password_hash, created_at)
      VALUES ($1, $2, $3, NOW())
      RETURNING id, name, email, created_at
    `;
    const values = [name, email, password];
    const result = await pool.query(query, values);
    return result.rows[0];
  }

  static async findById(id) {
    const query = 'SELECT id, name, email, created_at FROM users WHERE id = $1';
    const result = await pool.query(query, [id]);
    return result.rows[0];
  }

  static async findByEmail(email) {
    const query = 'SELECT * FROM users WHERE email = $1';
    const result = await pool.query(query, [email]);
    return result.rows[0];
  }
}

Performance Optimization

Caching Strategy

const redis = require('redis');
const client = redis.createClient({
  host: process.env.REDIS_HOST,
  port: process.env.REDIS_PORT
});

const cacheMiddleware = (duration = 300) => {
  return async (req, res, next) => {
    const key = `cache:${req.originalUrl}`;
    
    try {
      const cached = await client.get(key);
      if (cached) {
        return res.json(JSON.parse(cached));
      }
      
      // Store original res.json
      const originalJson = res.json;
      res.json = function(data) {
        // Cache the response
        client.setex(key, duration, JSON.stringify(data));
        originalJson.call(this, data);
      };
      
      next();
    } catch (error) {
      console.error('Cache error:', error);
      next();
    }
  };
};

Async/Await Best Practices

// Good: Parallel execution
const getUserData = async (userId) => {
  const [user, orders, preferences] = await Promise.all([
    UserModel.findById(userId),
    OrderModel.findByUserId(userId),
    PreferenceModel.findByUserId(userId)
  ]);
  
  return { user, orders, preferences };
};

// Good: Sequential when dependent
const processOrder = async (orderData) => {
  const user = await UserModel.findById(orderData.userId);
  if (!user) throw new Error('User not found');
  
  const order = await OrderModel.create(orderData);
  await EmailService.sendOrderConfirmation(user.email, order);
  
  return order;
};

Testing Your API

Unit Tests

const request = require('supertest');
const app = require('../app');

describe('User API', () => {
  test('GET /api/users should return users', async () => {
    const response = await request(app)
      .get('/api/users')
      .expect(200);
    
    expect(response.body.success).toBe(true);
    expect(Array.isArray(response.body.data)).toBe(true);
  });

  test('POST /api/users should create user', async () => {
    const userData = {
      name: 'John Doe',
      email: 'john@example.com',
      password: 'password123'
    };
    
    const response = await request(app)
      .post('/api/users')
      .send(userData)
      .expect(201);
    
    expect(response.body.success).toBe(true);
    expect(response.body.data.name).toBe(userData.name);
  });
});

Deployment Considerations

Environment Configuration

// config/database.js
const config = {
  development: {
    host: 'localhost',
    port: 5432,
    database: 'dev_db'
  },
  production: {
    host: process.env.DB_HOST,
    port: process.env.DB_PORT,
    database: process.env.DB_NAME,
    ssl: { rejectUnauthorized: false }
  }
};

module.exports = config[process.env.NODE_ENV || 'development'];

Health Check Endpoint

app.get('/health', async (req, res) => {
  try {
    // Check database connection
    await pool.query('SELECT 1');
    
    res.json({
      status: 'healthy',
      timestamp: new Date().toISOString(),
      uptime: process.uptime(),
      memory: process.memoryUsage()
    });
  } catch (error) {
    res.status(503).json({
      status: 'unhealthy',
      error: error.message
    });
  }
});

Conclusion

Building scalable APIs with Node.js and Express requires careful attention to architecture, performance, and best practices. By following these guidelines, you can create robust APIs that can handle growth and provide excellent user experiences.

At Dev Intelligence, we specialize in building scalable backend solutions using Node.js, Express, and other modern technologies. Our team has extensive experience in API design, database optimization, and performance tuning.

Ready to build your next API? Contact us to discuss your project requirements and discover how we can help you create a scalable, high-performance API solution.