Skip to main content

Express Load Testing

Introduction

Load testing is a critical practice in web application development that helps you understand how your Express.js application performs under expected and heavy loads. By simulating real-world usage patterns, load testing allows you to identify performance bottlenecks, determine system breaking points, and optimize your application for scalability before deploying to production.

In this guide, we'll explore various load testing approaches for Express applications, from basic tools to advanced techniques, helping you ensure your application can handle the demands of production traffic.

Why Load Testing Matters

Before diving into the tools and techniques, let's understand why load testing your Express applications is crucial:

  • Identify bottlenecks: Discover which parts of your application slow down under pressure
  • Determine capacity limits: Learn how many concurrent users your system can handle
  • Validate infrastructure decisions: Test if your server configuration and deployment strategy are appropriate
  • Prevent production failures: Catch performance issues before users experience them
  • Support scaling decisions: Gather data to make informed decisions about resources needed

Getting Started with Express Load Testing

Setting Up a Sample Express Application

Let's start with a simple Express application that we'll use for our load testing examples:

javascript
const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000;

// Simulate database query with delay
const simulateDbQuery = () => {
return new Promise(resolve => {
setTimeout(() => resolve({ data: 'Sample data' }), 100);
});
};

// Basic route
app.get('/', (req, res) => {
res.send('Hello World!');
});

// Route with simulated database operation
app.get('/users', async (req, res) => {
const result = await simulateDbQuery();
res.json(result);
});

// CPU intensive route
app.get('/compute', (req, res) => {
let result = 0;
for (let i = 0; i < 1000000; i++) {
result += Math.random();
}
res.json({ result });
});

app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});

This simple Express app has three endpoints:

  • / - A minimal endpoint that returns text
  • /users - An endpoint that simulates a database query with artificial latency
  • /compute - A CPU-intensive endpoint that demonstrates how computation affects response time

Basic Load Testing with Apache Bench

One of the simplest ways to start load testing is with Apache Bench (ab), a command-line tool that comes with Apache HTTP Server but can be used independently.

First, make sure your Express application is running. Then, use the following command to send 1000 requests with a concurrency of 100:

bash
ab -n 1000 -c 100 http://localhost:3000/

The output will look something like this:

This is ApacheBench, Version 2.3 <$Revision: 1879490 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking localhost (be patient)
Completed 100 requests
Completed 200 requests
...
Completed 900 requests
Completed 1000 requests
Finished 1000 requests


Server Software:
Server Hostname: localhost
Server Port: 3000

Document Path: /
Document Length: 11 bytes

Concurrency Level: 100
Time taken for tests: 0.538 seconds
Complete requests: 1000
Failed requests: 0
Total transferred: 211000 bytes
HTML transferred: 11000 bytes
Requests per second: 1858.43 [#/sec] (mean)
Time per request: 53.809 [ms] (mean)
Time per request: 0.538 [ms] (mean, across all concurrent requests)
Transfer rate: 383.31 [Kbytes/sec] received

Connection Times (ms)
min mean[+/-sd] median max
Connect: 0 2 1.4 2 11
Processing: 8 49 17.8 48 124
Waiting: 8 43 17.1 42 124
Total: 10 51 17.6 50 125

The most important metrics to look for are:

  • Requests per second - How many requests your application can handle per second
  • Time per request - Average time taken to serve each request
  • Failed requests - Number of requests that weren't successful

Let's try the CPU-intensive endpoint:

bash
ab -n 100 -c 10 http://localhost:3000/compute

You'll notice that the requests per second drastically decreases and time per request increases because of the computational work involved.

Advanced Load Testing with Artillery

While Apache Bench is good for basic testing, more comprehensive tools like Artillery allow for more realistic testing scenarios.

Setting Up Artillery

First, install Artillery globally:

bash
npm install -g artillery

Create a test configuration file named load-test.yml:

yaml
config:
target: "http://localhost:3000"
phases:
- duration: 60
arrivalRate: 5
rampTo: 50
name: "Warm up phase"
- duration: 120
arrivalRate: 50
name: "Sustained load"
defaults:
headers:
User-Agent: "Artillery Load Test"

scenarios:
- name: "Mixed endpoint test"
flow:
- get:
url: "/"
- think: 3
- get:
url: "/users"
- think: 5
- get:
url: "/compute"

This test will:

  1. Start with 5 virtual users per second and gradually ramp up to 50 over a minute
  2. Maintain 50 virtual users per second for 2 minutes
  3. For each virtual user, hit the three endpoints in sequence with pauses in between

Run the test with:

bash
artillery run load-test.yml

The output will provide detailed metrics after the test completes:

All virtual users finished
Summary report @ 15:42:38(+0300) 2023-08-15
Scenarios launched: 5398
Scenarios completed: 5398
Requests completed: 16194
Mean response/sec: 88.48
Response time (msec):
min: 0.4
max: 1453.5
median: 6.9
p95: 115.6
p99: 367.7
Scenario counts:
Mixed endpoint test: 5398 (100%)
Codes:
200: 16194

Monitoring During Load Tests

Simply running load tests isn't enough—you need to monitor your application's resources during the test to identify bottlenecks.

Using Node.js Built-in Monitoring

Node.js provides the built-in --inspect flag that lets you monitor your application's performance in Chrome DevTools.

Start your application with:

bash
node --inspect server.js

Open Chrome and navigate to chrome://inspect, then click on "Open dedicated DevTools for Node".

Using Express Monitoring Middleware

For more Express-specific metrics, you can use middleware like express-status-monitor:

bash
npm install express-status-monitor

Add it to your Express application:

javascript
const express = require('express');
const statusMonitor = require('express-status-monitor');

const app = express();

// Add status monitoring
app.use(statusMonitor());

// ... rest of your application code

Now you can access a dashboard at /status that shows metrics like:

  • Request rate
  • Response time
  • CPU usage
  • Memory usage
  • Status code distribution

Real-world Testing Strategies

In real-world applications, load testing should simulate actual user behavior as closely as possible.

Testing API Endpoints with Varied Payloads

Let's create an enhanced Artillery test that simulates users creating and retrieving data:

yaml
config:
target: "http://localhost:3000"
phases:
- duration: 60
arrivalRate: 20
payload:
path: "users.csv"
fields:
- "username"
- "email"

scenarios:
- name: "User registration and profile view"
flow:
- post:
url: "/api/users"
json:
username: "{{ username }}"
email: "{{ email }}"
capture:
- json: "$.id"
as: "userId"
- think: 2
- get:
url: "/api/users/{{ userId }}"

This test:

  1. Uses data from a CSV file to create users with unique usernames and emails
  2. Captures the user ID from the response
  3. Uses that ID to fetch the user profile

Testing Authentication Flows

Here's how to test an authentication flow:

yaml
config:
target: "http://localhost:3000"
phases:
- duration: 30
arrivalRate: 10

scenarios:
- name: "User login and protected resource access"
flow:
- post:
url: "/api/login"
json:
username: "testuser"
password: "password123"
capture:
- json: "$.token"
as: "authToken"
- think: 1
- get:
url: "/api/protected-resource"
headers:
Authorization: "Bearer {{ authToken }}"

Analyzing and Improving Performance

After running load tests, you'll typically find areas that need improvement. Here are some common Express.js performance optimizations:

1. Enable Compression

javascript
const compression = require('compression');
app.use(compression());

2. Implement Caching

javascript
const apicache = require('apicache');
let cache = apicache.middleware;

// Cache all routes
app.use(cache('5 minutes'));

// Or cache specific routes
app.get('/api/products', cache('1 hour'), (req, res) => {
// ...
});

3. Use a Production Process Manager

For production, use a process manager like PM2 to leverage multi-core systems:

bash
npm install -g pm2
pm2 start app.js -i max

This starts your application in cluster mode, creating one worker per CPU core.

4. Optimize Database Queries

If your application uses a database, make sure to optimize your queries:

javascript
// Instead of fetching all fields
app.get('/api/users', async (req, res) => {
const users = await User.find({});
res.json(users);
});

// Only fetch needed fields
app.get('/api/users', async (req, res) => {
const users = await User.find({}, 'name email'); // Only fetch name and email
res.json(users);
});

5. Implement Rate Limiting

javascript
const rateLimit = require('express-rate-limit');

const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100 // limit each IP to 100 requests per windowMs
});

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

Continuous Performance Testing

Ideally, load testing should be part of your continuous integration/continuous deployment (CI/CD) pipeline to catch performance regressions before they reach production.

Here's a simple example of how to integrate load testing into a GitHub Actions workflow:

yaml
name: Performance Testing

on:
push:
branches: [ main ]
pull_request:
branches: [ main ]

jobs:
load-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2

- name: Set up Node.js
uses: actions/setup-node@v2
with:
node-version: '16'

- name: Install dependencies
run: npm ci

- name: Start server in background
run: node server.js &

- name: Install Artillery
run: npm install -g artillery

- name: Wait for server to start
run: sleep 5

- name: Run load tests
run: artillery run load-test.yml -o test-report.json

- name: Check performance thresholds
run: |
MEDIAN_RESPONSE=$(jq '.aggregate.latency.median' test-report.json)
if (( $(echo "$MEDIAN_RESPONSE > 100" | bc -l) )); then
echo "Median response time too high: $MEDIAN_RESPONSE ms"
exit 1
fi

This workflow:

  1. Starts your Express server
  2. Runs Artillery load tests
  3. Verifies that the median response time is below 100ms
  4. Fails the build if performance doesn't meet the threshold

Summary

Load testing is an essential practice for ensuring Express applications can handle real-world traffic. In this guide, we've covered:

  • The importance of load testing for identifying bottlenecks and scaling issues
  • Basic load testing with Apache Bench
  • Advanced scenarios with Artillery
  • Monitoring your application during tests
  • Common performance optimizations for Express
  • Integrating load testing into your CI/CD pipeline

By implementing regular load testing in your development workflow, you can catch performance issues early, make data-driven optimizations, and deliver a faster, more reliable experience to your users.

Additional Resources and Exercises

Resources

Exercises

  1. Basic Load Test: Create a simple Express app with two endpoints—one that returns a static JSON response and another that performs a CPU-intensive operation. Compare their performance under load.

  2. Database Integration: Extend your app to include a database connection (MongoDB or similar). Write load tests that compare the performance of in-memory operations vs. database operations.

  3. Caching Comparison: Implement a caching solution like Redis for one of your routes. Create load tests that demonstrate the performance difference between cached and non-cached endpoints.

  4. Scaling Exercise: Deploy your application in both single-process mode and cluster mode (using PM2). Run identical load tests against both deployments and analyze the difference in throughput.

  5. CI Integration: Set up a GitHub Actions workflow that runs load tests on your Express application and fails if certain performance metrics (like p95 response time) exceed your defined thresholds.

By engaging with these exercises, you'll gain hands-on experience in load testing Express applications and develop practical skills for optimizing performance in real-world scenarios.



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)