Back to Blog
API
Backend
Performance
Architecture

Building Scalable APIs: Lessons from Production

Tyrone Mguni
January 10, 2024
8 min read
Share:

Building Scalable APIs: Lessons from Production


Over the past few years, I've had the opportunity to build and maintain APIs that serve millions of requests daily. Through trial, error, and quite a few production incidents, I've learned valuable lessons about what it takes to build truly scalable APIs.


The Foundation: Design Principles


1. RESTful Design (When It Makes Sense)


REST isn't just about using HTTP verbs correctly. It's about creating a consistent, predictable interface:


  • **Resource-based URLs**: `/users/123/posts` not `/getUserPosts?userId=123`
  • **Proper HTTP status codes**: 201 for creation, 204 for deletion, 422 for validation errors
  • **Consistent error responses**: Always return errors in the same format

  • 2. API Versioning Strategy


    Version your APIs from day one. I prefer URL versioning (/api/v1/users) because it's explicit and easy to understand.


    3. Documentation as a First-Class Citizen


    Use tools like OpenAPI/Swagger to generate documentation from your code. Keep it updated and include examples for every endpoint.


    Performance Lessons


    Database Query Optimization


    The biggest performance killer I've encountered is the N+1 query problem. Here's what I've learned:


    Bad:

    // This will make N+1 queries

    const users = await User.findAll();

    for (const user of users) {

    user.posts = await Post.findAll({ where: { userId: user.id } });

    }


    Good:

    // Single query with joins

    const users = await User.findAll({

    include: [{ model: Post }]

    });


    Caching Strategy


    Implement caching at multiple levels:


    1. Application-level caching: Redis for frequently accessed data

    2. Database query caching: Reduce repeated database hits

    3. HTTP caching: Use ETags and Cache-Control headers

    4. CDN caching: For static or semi-static content


    Rate Limiting


    Implement rate limiting early. I use a sliding window approach:


    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'

    });


    Scaling Strategies


    Horizontal vs Vertical Scaling


    Vertical Scaling (scaling up):

  • Easier to implement
  • Limited by hardware constraints
  • Single point of failure

  • Horizontal Scaling (scaling out):

  • More complex but unlimited potential
  • Requires stateless design
  • Better fault tolerance

  • Load Balancing


    Use a load balancer to distribute traffic across multiple API instances. Consider:

  • **Round-robin**: Simple but doesn't account for server load
  • **Least connections**: Better for varying request complexity
  • **Health checks**: Automatically remove unhealthy servers

  • Database Scaling


    1. Read Replicas: Separate read and write operations

    2. Sharding: Distribute data across multiple databases

    3. Connection Pooling: Reuse database connections efficiently


    Error Handling and Monitoring


    Comprehensive Error Handling


    app.use((err, req, res, next) => {

    // Log the error

    logger.error('API Error:', {

    error: err.message,

    stack: err.stack,

    url: req.url,

    method: req.method,

    userAgent: req.get('User-Agent'),

    ip: req.ip

    });


    // Don't leak internal errors to clients

    if (err.isOperational) {

    res.status(err.statusCode).json({

    status: 'error',

    message: err.message

    });

    } else {

    res.status(500).json({

    status: 'error',

    message: 'Something went wrong'

    });

    }

    });


    Monitoring and Alerting


    Track key metrics:

  • **Response times**: 50th, 95th, and 99th percentiles
  • **Error rates**: 4xx and 5xx responses
  • **Throughput**: Requests per second
  • **Database performance**: Query times and connection pool usage

  • Security Considerations


    Authentication and Authorization


    Use JWT tokens with proper expiration times:


    const jwt = require('jsonwebtoken');


    // Generate token

    const token = jwt.sign(

    { userId: user.id, role: user.role },

    process.env.JWT_SECRET,

    { expiresIn: '1h' }

    );


    // Verify token middleware

    const authenticateToken = (req, res, next) => {

    const authHeader = req.headers['authorization'];

    const token = authHeader && authHeader.split(' ')[1];


    if (!token) {

    return res.status(401).json({ message: 'Access token required' });

    }


    jwt.verify(token, process.env.JWT_SECRET, (err, user) => {

    if (err) return res.status(403).json({ message: 'Invalid token' });

    req.user = user;

    next();

    });

    };


    Input Validation


    Never trust client input. Use a schema validation library:


    const Joi = require('joi');


    const userSchema = Joi.object({

    email: Joi.string().email().required(),

    password: Joi.string().min(8).required(),

    age: Joi.number().integer().min(13).max(120)

    });


    const { error, value } = userSchema.validate(req.body);

    if (error) {

    return res.status(400).json({ message: error.details[0].message });

    }


    Testing Strategy


    Unit Tests


    Test individual functions and methods in isolation:


    describe('User Service', () => {

    it('should create a user with valid data', async () => {

    const userData = { email: 'test@example.com', password: 'password123' };

    const user = await UserService.create(userData);


    expect(user.email).toBe(userData.email);

    expect(user.password).not.toBe(userData.password); // Should be hashed

    });

    });


    Integration Tests


    Test API endpoints end-to-end:


    describe('POST /api/users', () => {

    it('should create a new user', async () => {

    const response = await request(app)

    .post('/api/users')

    .send({ email: 'test@example.com', password: 'password123' })

    .expect(201);


    expect(response.body.user.email).toBe('test@example.com');

    });

    });


    Load Testing


    Use tools like Artillery or k6 to test performance under load:


    config:

    target: 'http://localhost:3000'

    phases:

    - duration: 60

    arrivalRate: 10

    scenarios:

    - name: "Get users"

    flow:

    - get:

    url: "/api/users"


    Deployment and DevOps


    CI/CD Pipeline


    Automate testing and deployment:


    1. Continuous Integration: Run tests on every commit

    2. Automated Deployment: Deploy to staging automatically

    3. Manual Production Deploy: With proper approvals

    4. Rollback Strategy: Quick rollback if issues arise


    Environment Management


    Use environment variables for configuration:


    const config = {

    port: process.env.PORT || 3000,

    database: {

    host: process.env.DB_HOST,

    port: process.env.DB_PORT,

    name: process.env.DB_NAME

    },

    jwt: {

    secret: process.env.JWT_SECRET,

    expiresIn: process.env.JWT_EXPIRES_IN || '1h'

    }

    };


    Common Pitfalls and How to Avoid Them


    1. Not Planning for Scale from the Start


    Even if you don't need it immediately, design your API with scaling in mind:

  • Use stateless design
  • Implement proper caching early
  • Choose technologies that can scale

  • 2. Ignoring Database Indexes


    Profile your queries and add indexes for frequently queried fields:


    -- Add index for email lookups

    CREATE INDEX idx_users_email ON users(email);


    -- Composite index for complex queries

    CREATE INDEX idx_posts_user_date ON posts(user_id, created_at);


    3. Poor Error Messages


    Provide meaningful error messages that help developers integrate with your API:


    // Bad

    { "error": "Invalid input" }


    // Good

    {

    "error": "Validation failed",

    "details": [

    {

    "field": "email",

    "message": "Email is required"

    },

    {

    "field": "password",

    "message": "Password must be at least 8 characters"

    }

    ]

    }


    Conclusion


    Building scalable APIs is as much about planning and architecture as it is about code. The key lessons I've learned:


    1. Design for scale from the beginning

    2. Monitor everything and alert on anomalies

    3. Cache aggressively but invalidate carefully

    4. Test thoroughly at all levels

    5. Secure by default, not as an afterthought


    Every production incident taught me something valuable. Embrace failures as learning opportunities, and always conduct post-mortems to prevent similar issues.


    The API landscape continues to evolve with GraphQL, gRPC, and other technologies, but these fundamental principles remain constant.


    ---


    *Have you faced similar challenges building scalable APIs? I'd love to hear about your experiences and lessons learned.*


    About the Author

    Tyrone Mguni is a full-stack software engineer with over 5 years of experience building scalable web applications. He specializes in React, Node.js, and cloud architecture.