Benchmark a Node.js API to 1M RPS

Optimize a Node.js API through four stages and benchmark each improvement.

Introduction

30 Second Summary

When your app starts getting real traffic, you need to know exactly where your server's limits are. The difference between a fast API and a slow one often comes down to a few specific bottlenecks that are invisible until you measure them.

In this project, you will build a Node.js API and systematically optimize it through four stages. At each stage, you will benchmark your server's throughput to see exactly where performance improves and why.

What You'll Build

A high-performance Node.js API optimized through four progressive stages, with benchmark results documenting the throughput gains at each layer.

By the end of this project, you'll have:

  • A benchmarked comparison of Express vs Fastify with real throughput numbers from your own machine.
  • A clustered Node.js API running across all CPU cores with measured scaling gains using PM2.
  • A Redis-cached read path demonstrating the performance jump from in-memory reads vs disk-based PostgreSQL queries.
  • A React dashboard with live charts visualizing your benchmark progression and a route tester that hits your API from the browser.
  • Secret Mission: Implement the Redis write-buffer pattern used for high-write throughput, writing to Redis first and syncing to Postgres in the background.

Are there any prerequisites?

You should have Node.js and PostgreSQL already installed on your machine. Basic experience building APIs (understanding routes, requests, and responses) will help you get the most out of this project. No prior React experience is needed for the dashboard step.

Before We Start

Performance bottlenecks are invisible until you measure them. In this project, you will build a Node.js API and optimize it through four stages, benchmarking at each stage to produce concrete throughput numbers from your own hardware.

Before writing any application code, you need a stack of performance tools ready to go. This step installs everything you need: a load testing tool, a process manager, a caching layer, and the database that backs your API.

In this step, get ready to:
  • Install Redis, autocannon, and PM2.
  • Initialize the project directory and install dependencies.
  • Create the PostgreSQL benchmark database and verify the full stack.
Install your performance tools

You need three tools that are not part of a typical Node.js project. Each one targets a different layer of the performance stack.

  • Redis is an in-memory data store you will use as a caching layer in front of PostgreSQL.
  • autocannon is a load testing tool that fires thousands of HTTP requests at your server and measures throughput.
  • PM2 is a process manager that runs your server in cluster mode across all CPU cores.
  • Install Redis by running this command:
brew install redis
  • Start Redis as a background service by running:
brew services start redis

What do these commands do?

The first command downloads and installs the Redis server binary through Homebrew. The second starts Redis as a background service so it stays running without needing a dedicated terminal window.

  • Verify Redis is running by running:
redis-cli ping

✔️ I see PONG

Redis is running and accepting connections. You are good to continue.

ⓧ Connection refused or command not found

If you see a connection refused error, Redis did not start properly. Try restarting it:

brew services restart redis

If the redis-cli command itself is not found, the Homebrew install may not have linked correctly. Run:

brew link redis

Then try redis-cli ping again.

  • Install autocannon and PM2 globally by running these commands:
npm i autocannon -g
npm install pm2 -g

Why install globally?

Global installs make autocannon and pm2 available as commands anywhere in your terminal. You will use them to benchmark and manage servers across multiple project files.

  • Confirm both tools installed by checking their versions:
autocannon --version
pm2 --version

✔️ I see version numbers for both

Both tools are installed and available globally. Move on to the next substep.

ⓧ Command not found

If either command is not found, the global npm directory may not be in your PATH. Run this to find where npm installs globals:

npm config get prefix

Make sure the bin folder inside that path is in your shell PATH. You may need to restart your terminal after modifying PATH.

Initialize the project directory

Now you will create the project folder and install the four npm packages that power your benchmark server.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir node-perf-benchmark
cd node-perf-benchmark
  • Create a file called package.json in the node-perf-benchmark folder with the following content:
{
  "name": "node-perf-benchmark",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "seed": "node seed.js",
    "sync": "node sync-worker.js"
  },
  "dependencies": {
    "express": "4.21.2",
    "fastify": "5.3.3",
    "ioredis": "5.6.0",
    "pg": "8.14.1"
  }
}

What does this package.json do?

The "type": "module" field enables ES module syntax (import/export) instead of CommonJS require().

The four dependencies are: Express (your baseline server framework), Fastify (the high-performance alternative), ioredis (Redis client for Node.js), and pg (PostgreSQL client).

  • Install all dependencies by running:
npm install
  • Save package.json.
  • Confirm the install succeeded by running:
ls node_modules

You should see a long list of folders including express, fastify, ioredis, and pg.

Seeing permission errors?

If npm install fails with EACCES or permission denied, your global npm config may be interfering. Run the install with the --legacy-peer-deps flag, or check that your node_modules directory is writable.

Help me debug my npm install error.

✔️ Awesome, I've got everything!

Great. Double check you have saved package.json before moving on.

ⓧ I'd like to double check the full code

Here is the complete package.json you should have:

{
  "name": "node-perf-benchmark",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "seed": "node seed.js",
    "sync": "node sync-worker.js"
  },
  "dependencies": {
    "express": "4.21.2",
    "fastify": "5.3.3",
    "ioredis": "5.6.0",
    "pg": "8.14.1"
  }
}
Create the benchmark database and verify the stack

Your API will read and write to a PostgreSQL database called benchmark. You need to create it now so the server can connect when you build it in the next step.

  • Create the database by running:
createdb benchmark

What does createdb do?

The createdb command is a PostgreSQL utility that creates a new database. It produces no output on success. The database is now available at localhost:5432.

  • Create the codes table inside the new database by running:
psql benchmark -c "CREATE TABLE codes (id SERIAL PRIMARY KEY, code VARCHAR(500) NOT NULL, created_at TIMESTAMP DEFAULT NOW())"

You should see CREATE TABLE printed in your terminal.

  • Verify PostgreSQL accepts connections to your new database by running:
psql benchmark -c "SELECT COUNT(*) FROM codes"

✔️ I see count = 0

PostgreSQL is connected and the codes table exists. The count is 0 because you have not seeded any data yet.

ⓧ Database does not exist or connection refused

If you see a "database does not exist" error, the createdb command may have failed silently. Make sure PostgreSQL is running:

brew services start postgresql

Then try createdb benchmark again. If you see a "role does not exist" error, your PostgreSQL user may differ from your macOS username. Check your PostgreSQL user with:

psql postgres -c "\du"

Help me connect to my PostgreSQL database.

  • Run one final check to confirm Redis is still responding:
redis-cli ping

You should see PONG. Your full stack is ready: Redis is running in the background, PostgreSQL has a benchmark database with an empty codes table, and all global CLI tools are installed.

Your performance lab is ready. Next up, you will build the baseline Express API and fire your first load test at it to see how it holds up under pressure.

Build the Baseline Express API

Your environment is set up and every tool is verified. Now you need something to actually benchmark.

In this step, you will build an Express API with four routes that each stress a different part of the system. One returns pure JSON, one generates a large payload with CPU work, one reads from PostgreSQL, and one writes to PostgreSQL. These give you a complete picture of where Express spends its time under load.

In this step, get ready to:
  • Create an Express server with four routes that exercise different bottleneck types.
  • Seed the database with 100,000 records and verify all routes respond correctly.
  • Run your first autocannon benchmarks and record baseline throughput numbers.
Create the database connection module

Every route that touches PostgreSQL needs a connection pool. A shared pool avoids opening a new TCP connection for every single request.

  • Create a file called db.js in your node-perf-benchmark folder with the following code:
import pg from "pg";

// Create a connection pool for the benchmark database
const pool = new pg.Pool({
  user: "[[POSTGRES_USER=\"your-postgres-username-here\"]]",
  host: "localhost",
  database: "benchmark",
  password: "",
  port: 5432,
});

export default pool;

What does this code do?

This creates a PostgreSQL connection pool using the pg library. A pool keeps a set of reusable connections open so each database query does not pay the cost of establishing a new TCP connection.

Replace the user value with your actual PostgreSQL username. On macOS, this is typically your system username. You can find it by running whoami in your terminal.

  • Save db.js.
  • Confirm the file exists by running:
ls db.js

You should see db.js listed in the output.

Don't see the file?

Make sure you saved the file inside your node-perf-benchmark project folder, not a parent directory. Run pwd to check your current location.

Help me find where my db.js file was saved

Build the Express server with four routes

You will create an Express server with four routes. Each one isolates a different bottleneck type so you can see exactly where Express slows down under concurrent load.

  • Create a file called express-server.js in your node-perf-benchmark folder with the imports and setup:
import express from "express";
import crypto from "node:crypto";
import pool from "./db.js";

const app = express();
app.use(express.json());

What does this code do?

This imports Express for HTTP handling, Node's built-in crypto module for generating random data, and the database pool you just created. The express.json() middleware parses incoming JSON request bodies.

  • Add the first route below the setup code. This is the simplest possible endpoint:
// Simplest possible route - pure JSON serialization
app.get("/simple", (req, res) => {
  res.json({ message: "hi" });
});

What does this route test?

GET /simple returns a tiny JSON object with zero computation and zero database access. It measures pure framework overhead: how fast Express can serialize and send a response.

  • Add the CPU-intensive route below the /simple route:
// CPU-intensive: generate 100 objects with random hex strings
app.patch("/data", (req, res) => {
  const items = Array.from({ length: 100 }, (_, i) => ({
    id: i,
    value: crypto.randomBytes(16).toString("hex"),
    timestamp: Date.now(),
  }));
  res.json({ items, count: items.length });
});

What does this route test?

PATCH /data generates 100 objects with cryptographic random values. This blocks the event loop while it runs, simulating CPU-bound work that prevents Node.js from handling other requests.

  • Add the database read route below /data:
// IO-bound read: fetch a random record from PostgreSQL
app.get("/code/:id", async (req, res) => {
  const id = Math.floor(Math.random() * 100000) + 1;
  const result = await pool.query(
    "SELECT id, code FROM codes WHERE id = $1",
    [id]
  );
  res.json(result.rows[0] || { error: "not found" });
});

What does this route test?

GET /code/:id queries PostgreSQL for a random record. The event loop is free while waiting for the database response, making this IO-bound. Performance here depends on database latency, not CPU.

  • Add the database write route and the server startup below /code/:id:
// IO-bound write: insert a new record into PostgreSQL
app.post("/code", async (req, res) => {
  const code = crypto.randomBytes(250).toString("hex");
  const result = await pool.query(
    "INSERT INTO codes (code) VALUES ($1) RETURNING id, created_at",
    [code]
  );
  res.json(result.rows[0]);
});

const PORT = 3001;
app.listen(PORT, () => {
  console.log(`Express server running at http://localhost:${PORT}`);
});

What does this route test?

POST /code inserts a new row into PostgreSQL. Writes are typically slower than reads because the database must flush data to disk for durability.

Together these four routes cover the full spectrum from pure framework overhead to real database workloads.

  • Save express-server.js.
  • Start the server by running:
node express-server.js

You should see Express server running at http://localhost:3001 printed in the terminal.

  • Open a new terminal tab by pressing Cmd+T (macOS) or Ctrl+Shift+T (Windows).
  • Test the simple route by running:
curl http://localhost:3001/simple

You should see {"message":"hi"} printed in the terminal.

  • Go back to the first terminal tab and stop the server with Ctrl+C.

Seeing a connection error?

If curl reports connection refused, check that the first terminal shows the startup message. If you see a PostgreSQL error instead, double-check the user value in db.js matches your system username.

Help me debug my Express server startup error

✔️ Awesome, I've got everything!

Great. Double check you have saved express-server.js with all four routes before moving on.

ⓧ I'd like to double check the full code

Here is the complete express-server.js at this point:

import express from "express";
import crypto from "node:crypto";
import pool from "./db.js";

const app = express();
app.use(express.json());

app.get("/simple", (req, res) => {
  res.json({ message: "hi" });
});

app.patch("/data", (req, res) => {
  const items = Array.from({ length: 100 }, (_, i) => ({
    id: i,
    value: crypto.randomBytes(16).toString("hex"),
    timestamp: Date.now(),
  }));
  res.json({ items, count: items.length });
});

app.get("/code/:id", async (req, res) => {
  const id = Math.floor(Math.random() * 100000) + 1;
  const result = await pool.query(
    "SELECT id, code FROM codes WHERE id = $1",
    [id]
  );
  res.json(result.rows[0] || { error: "not found" });
});

app.post("/code", async (req, res) => {
  const code = crypto.randomBytes(250).toString("hex");
  const result = await pool.query(
    "INSERT INTO codes (code) VALUES ($1) RETURNING id, created_at",
    [code]
  );
  res.json(result.rows[0]);
});

const PORT = 3001;
app.listen(PORT, () => {
  console.log(`Express server running at http://localhost:${PORT}`);
});
Seed the database and verify all routes

The GET /code/:id route picks a random ID between 1 and 100,000. For it to return real data, you need 100,000 records in the table.

  • Create a file called seed.js in your node-perf-benchmark folder with the following code:
import crypto from "node:crypto";
import pool from "./db.js";

const RECORD_COUNT = 100000;

async function seed() {
  // Drop and recreate the table for a clean slate
  await pool.query("DROP TABLE IF EXISTS codes");
  await pool.query(`
    CREATE TABLE codes (
      id SERIAL PRIMARY KEY,
      code VARCHAR(500) NOT NULL,
      created_at TIMESTAMP DEFAULT NOW()
    )
  `);

  // Insert records in batches of 1000 for speed
  const batchSize = 1000;
  for (let i = 0; i < RECORD_COUNT; i += batchSize) {
    const values = [];
    const params = [];
    for (let j = 0; j < batchSize && i + j < RECORD_COUNT; j++) {
      const idx = values.length / 1 + 1;
      values.push(`($${idx})`);
      params.push(crypto.randomBytes(250).toString("hex"));
    }
    await pool.query(
      `INSERT INTO codes (code) VALUES ${values.join(", ")}`,
      params
    );
  }

  console.log(`Seeded ${RECORD_COUNT} records into codes table.`);
  await pool.end();
}

seed();

What does this script do?

The seed script drops the existing codes table and recreates it fresh. Then it inserts 100,000 rows in batches of 1,000. Each row contains a 500-character random hex string simulating a realistic data payload.

Batching keeps the insert fast. Inserting one row at a time would take significantly longer due to per-query network round-trip overhead.

  • Save seed.js and run the seed script:
npm run seed

This takes about 10-30 seconds depending on your machine. You should see this output when it finishes:

Seeded 100000 records into codes table.

Seed script hanging or erroring?

If you see a connection refused error, confirm PostgreSQL is running. If you see an authentication error, double-check the user value in db.js matches your PostgreSQL username.

Help me fix my seed script error

Now verify every route works with the seeded data.

  • Start the Express server again:
node express-server.js
  • In your second terminal tab, test each route by running these commands one at a time:
curl http://localhost:3001/simple
curl -X PATCH http://localhost:3001/data
curl http://localhost:3001/code/1
curl -X POST http://localhost:3001/code

What should I see?

  • /simple returns {"message":"hi"}.
  • /data returns a large JSON array with 100 objects containing random hex values.
  • /code/1 returns a JSON object with id and code fields (a long hex string).
  • /code (POST) returns a JSON object with id and created_at fields.
  • Keep the server running for the next substep.

✔️ Awesome, I've got everything!

Great. Make sure seed.js is saved and that all four curl commands returned valid JSON.

ⓧ I'd like to double check the full code

Here is the complete seed.js:

import crypto from "node:crypto";
import pool from "./db.js";

const RECORD_COUNT = 100000;

async function seed() {
  await pool.query("DROP TABLE IF EXISTS codes");
  await pool.query(`
    CREATE TABLE codes (
      id SERIAL PRIMARY KEY,
      code VARCHAR(500) NOT NULL,
      created_at TIMESTAMP DEFAULT NOW()
    )
  `);

  const batchSize = 1000;
  for (let i = 0; i < RECORD_COUNT; i += batchSize) {
    const values = [];
    const params = [];
    for (let j = 0; j < batchSize && i + j < RECORD_COUNT; j++) {
      const idx = values.length / 1 + 1;
      values.push(`($${idx})`);
      params.push(crypto.randomBytes(250).toString("hex"));
    }
    await pool.query(
      `INSERT INTO codes (code) VALUES ${values.join(", ")}`,
      params
    );
  }

  console.log(`Seeded ${RECORD_COUNT} records into codes table.`);
  await pool.end();
}

seed();
Run your first autocannon benchmarks

With all routes working, it is time to measure how fast Express handles concurrent load. You will use autocannon to fire many simultaneous requests and record the requests per second for each route.

What is autocannon?

autocannon is an HTTP benchmarking tool written in Node.js. The -c flag sets the number of concurrent connections, -d sets the duration in seconds, and -m sets the HTTP method. It reports latency percentiles and total requests per second.

  • Make sure your Express server is still running in the first terminal tab. In your second terminal tab, benchmark the /simple route by running:
autocannon -c 100 -d 10 http://localhost:3001/simple

What does this command do?

This opens 100 concurrent connections (-c 100) and sends as many requests as possible for 10 seconds (-d 10). At the end, autocannon prints a table showing average latency, total requests, and requests per second.

Wait for the benchmark to finish (10 seconds). You will see a results table with your RPS number.

  • Benchmark the CPU-intensive route by running:
autocannon -c 100 -d 10 -m PATCH http://localhost:3001/data
  • Benchmark the database read route by running:
autocannon -c 100 -d 10 http://localhost:3001/code/1

Why not benchmark POST /code?

Running autocannon against a write route with 100 connections for 10 seconds would insert tens of thousands of extra rows. You will benchmark writes later with controlled parameters. For now, focus on the three non-write routes to establish a clean baseline.

Write down the RPS (requests per second) number from each benchmark result. These are your Express baseline numbers that you will compare against in every future optimization step.

Save your numbers somewhere

Create a simple text file or note with three rows: /simple RPS, /data RPS, and /code/:id RPS. You will add columns for Fastify, clustering, and Redis caching in the following steps.

  • Stop the Express server with Ctrl+C in the first terminal tab.

Seeing very low numbers or timeouts?

If autocannon reports many timeouts, check that nothing else is using port 3001. Close any other Node processes. If /code/:id shows significantly lower RPS than /simple, that is expected. Database IO adds latency.

Help me understand my autocannon benchmark results

You now have concrete numbers showing exactly how Express performs under load. Next up, you will create the same routes in Fastify and measure the difference a framework swap makes.

Swap to Fastify and Measure the Difference

You now have hard numbers for Express. You know exactly how many requests per second each route handles under load.

The next question is: how much of that ceiling is the framework itself? Fastify is built from the ground up for speed. It uses a radix-tree router and compiled JSON serializers instead of Express's middleware chain. In this step, you will create the same routes in Fastify, benchmark them with identical parameters, and see exactly where the framework overhead shows up.

In this step, get ready to:
  • Create a Fastify server with the same routes, using schema-based serialization and Redis caching.
  • Benchmark all three routes with identical autocannon parameters.
  • Analyze which routes show the largest throughput gap and why.
Create the Redis client and Fastify server

The Fastify server will use Redis to cache database reads. Before building the server, you need a small module that exports the Redis connection so multiple files can share it.

  • Create a new file called redis-client.js in your project folder and add the following code:
import Redis from "ioredis";

const redis = new Redis();

export default redis;

What does this code do?

This creates a single Redis client instance using the ioredis library. It connects to the default Redis port (6379) on localhost.

By exporting a shared instance, every file that imports it reuses the same connection pool instead of opening new connections.

  • Save redis-client.js.
  • Confirm the file exists by running:
ls redis-client.js

You should see redis-client.js listed in the output.

Now create the Fastify server. The first route demonstrates Fastify's key performance feature: schema-based response serialization.

  • Create a new file called fastify-server.js and add the imports, initialization, and the /simple route:
import Fastify from "fastify";
import crypto from "node:crypto";
import pool from "./db.js";
import redis from "./redis-client.js";

const fastify = Fastify({ logger: false });

fastify.get("/simple", {
  schema: {
    response: {
      200: {
        type: "object",
        properties: {
          message: { type: "string" },
        },
      },
    },
  },
  handler: async () => {
    return { message: "hi" };
  },
});

What does this code do?

The imports bring in the same modules as Express, plus the Redis client you just created.

The /simple route includes a schema object describing the response shape. Fastify uses this schema to compile a serializer at startup time, bypassing JSON.stringify on every request. This is where the biggest speed difference shows up on simple routes.

  • Below the /simple route, add the CPU-intensive /data route:
fastify.patch("/data", async () => {
  const items = Array.from({ length: 100 }, (_, i) => ({
    id: i,
    value: crypto.randomBytes(16).toString("hex"),
    timestamp: Date.now(),
  }));
  return { items, count: items.length };
});

What does this code do?

This is identical logic to the Express version. It generates 100 objects with random hex strings. The only difference is that Fastify handlers return the object directly instead of calling res.json(). Fastify handles serialization internally.

  • Below the /data route, add the /code/:id route with Redis cache-aside:
fastify.get("/code/:id", async () => {
  const id = Math.floor(Math.random() * 100000) + 1;
  const cacheKey = `code:${id}`;

  // Check Redis cache first
  const cached = await redis.get(cacheKey);
  if (cached) {
    return JSON.parse(cached);
  }

  // Cache miss - read from PostgreSQL
  const result = await pool.query(
    "SELECT id, code FROM codes WHERE id = $1",
    [id]
  );
  const row = result.rows[0] || { error: "not found" };

  // Store in Redis with 60-second TTL
  await redis.set(cacheKey, JSON.stringify(row), "EX", 60);
  return row;
});

What is cache-aside?

The cache-aside pattern checks Redis before hitting the database. On a cache miss, it reads from PostgreSQL and stores the result in Redis with a 60-second expiration (EX, 60). On the next request for the same ID, the data comes from Redis without touching the database at all.

During benchmarking, the first request for each ID is a cache miss. Subsequent requests for the same ID are cache hits. Over a 10-second test with random IDs from 1 to 100,000, you will see a mix of hits and misses.

  • Below the /code/:id route, add the two write routes and the server startup:
fastify.post("/code", async () => {
  const code = crypto.randomBytes(250).toString("hex");
  const result = await pool.query(
    "INSERT INTO codes (code) VALUES ($1) RETURNING id, created_at",
    [code]
  );
  return result.rows[0];
});

fastify.post("/code-fast", async () => {
  const id = crypto.randomUUID();
  const code = crypto.randomBytes(250).toString("hex");

  await redis.hset(`code:${id}`, "id", id, "code", code, "created_at", new Date().toISOString());
  await redis.lpush("sync_queue", id);

  return { id, created_at: new Date().toISOString() };
});

const PORT = process.env.PORT || 3000;
fastify.listen({ port: PORT, host: "0.0.0.0" }, (err) => {
  if (err) {
    console.error(err);
    process.exit(1);
  }
  console.log(`Fastify server running at http://localhost:${PORT}`);
});

What do these routes do?

POST /code writes directly to PostgreSQL, identical to Express. POST /code-fast writes to Redis instead and pushes the ID onto a sync queue. You will use /code-fast in the Secret Mission to compare write strategies.

The server listens on port 3000 by default, keeping it separate from Express on port 3001 so you can run them side by side if needed.

  • Save fastify-server.js.
  • Start the Fastify server by running:
node fastify-server.js

You should see Fastify server running at http://localhost:3000 in your terminal.

  • Verify the routes work by running these curl commands in a new terminal tab:
curl http://localhost:3000/simple
curl -X PATCH http://localhost:3000/data
curl http://localhost:3000/code/1

Each command should return JSON. The /simple route returns {"message":"hi"}. The /data route returns an array of 100 items. The /code/1 route returns a database record.

Server won't start?

Make sure Redis is running. If you see a connection error, start Redis with redis-server in a separate terminal tab.

Also confirm your Express server from Step 2 is not still running on port 3000. Stop it with Ctrl+C if needed.

help me debug my Fastify server startup error

✔️ Awesome, I've got everything!

Great. Double check you have saved both redis-client.js and fastify-server.js.

ⓧ I'd like to double check the full code

import Redis from "ioredis";

const redis = new Redis();

export default redis;
import Fastify from "fastify";
import crypto from "node:crypto";
import pool from "./db.js";
import redis from "./redis-client.js";

const fastify = Fastify({ logger: false });

fastify.get("/simple", {
  schema: {
    response: {
      200: {
        type: "object",
        properties: {
          message: { type: "string" },
        },
      },
    },
  },
  handler: async () => {
    return { message: "hi" };
  },
});

fastify.patch("/data", async () => {
  const items = Array.from({ length: 100 }, (_, i) => ({
    id: i,
    value: crypto.randomBytes(16).toString("hex"),
    timestamp: Date.now(),
  }));
  return { items, count: items.length };
});

fastify.get("/code/:id", async () => {
  const id = Math.floor(Math.random() * 100000) + 1;
  const cacheKey = `code:${id}`;

  const cached = await redis.get(cacheKey);
  if (cached) {
    return JSON.parse(cached);
  }

  const result = await pool.query(
    "SELECT id, code FROM codes WHERE id = $1",
    [id]
  );
  const row = result.rows[0] || { error: "not found" };

  await redis.set(cacheKey, JSON.stringify(row), "EX", 60);
  return row;
});

fastify.post("/code", async () => {
  const code = crypto.randomBytes(250).toString("hex");
  const result = await pool.query(
    "INSERT INTO codes (code) VALUES ($1) RETURNING id, created_at",
    [code]
  );
  return result.rows[0];
});

fastify.post("/code-fast", async () => {
  const id = crypto.randomUUID();
  const code = crypto.randomBytes(250).toString("hex");

  await redis.hset(`code:${id}`, "id", id, "code", code, "created_at", new Date().toISOString());
  await redis.lpush("sync_queue", id);

  return { id, created_at: new Date().toISOString() };
});

const PORT = process.env.PORT || 3000;
fastify.listen({ port: PORT, host: "0.0.0.0" }, (err) => {
  if (err) {
    console.error(err);
    process.exit(1);
  }
  console.log(`Fastify server running at http://localhost:${PORT}`);
});
Benchmark all three Fastify routes

You will now run the same benchmarks you ran against Express, using identical autocannon parameters. This gives you a direct apples-to-apples comparison.

  • Make sure your Fastify server is still running on port 3000.
  • In a separate terminal tab, benchmark the /simple route by running:
autocannon -c 100 -d 10 http://localhost:3000/simple

What do these flags mean?

-c 100 opens 100 concurrent connections. -d 10 runs the test for 10 seconds. These are the same parameters you used for Express, making the comparison valid.

Note down the requests per second (req/sec) average from the output.

  • Benchmark the /data route by running:
autocannon -c 100 -d 10 -m PATCH http://localhost:3000/data

Why the -m flag?

The -m PATCH flag tells autocannon to use the PATCH HTTP method instead of the default GET. This matches the route definition in your server.

Note down the req/sec average.

  • Benchmark the /code/:id route by running:
autocannon -c 100 -d 10 http://localhost:3000/code/1

Note down the req/sec average. This route will be a mix of Redis cache hits and PostgreSQL reads.

Getting very low numbers?

Make sure no other heavy processes are running on your machine. Close unused browser tabs and applications to free CPU.

If the /code/:id route shows connection errors, confirm PostgreSQL is running and the benchmark database has data from your seed script.

help me troubleshoot low autocannon benchmark numbers

Analyze the results

You now have two sets of numbers: Express throughput and Fastify throughput for the same three routes. The comparison reveals where framework overhead actually matters.

What should I expect to see?

The /simple route shows the biggest gap. On simple JSON responses, Fastify's compiled serializer and radix-tree router eliminate the per-request overhead that Express's middleware chain adds. You may see 3-5x higher throughput on this route alone.

The /data route shows a moderate gap. The CPU work (generating 100 random objects) dominates the request time, so framework overhead is a smaller percentage of the total.

The /code/:id route shows the smallest gap on cache misses. When the bottleneck is network I/O to PostgreSQL, the framework layer contributes very little to total latency. Cache hits will be faster since they skip the database entirely.

💡 Why does framework overhead matter most on fast routes?

Think of it as a percentage. If a request takes 0.1ms of actual work, and the framework adds 0.05ms of overhead, that overhead is 50% of the total time. But if a request takes 10ms of database I/O, the same 0.05ms overhead is only 0.5% of total time.

This is why switching frameworks gives dramatic gains on lightweight routes but diminishing returns on database-heavy ones. The real wins for database routes come from caching (which you already added) and clustering (which comes next).

  • Record your numbers in a comparison like this:
# Example results (your numbers will differ based on hardware)
# Route          | Express RPS | Fastify RPS | Improvement
# /simple        |   18,000    |   85,000    |    4.7x
# /data (PATCH)  |    8,000    |   22,000    |    2.8x
# /code/:id      |   12,000    |   15,000    |    1.3x

What do my numbers mean for system design?

Your numbers tell a clear story: framework choice matters most when the per-request work is minimal. For APIs that serve cached data or static responses (health checks, feature flags, config endpoints), Fastify delivers massive throughput gains. For database-heavy CRUD operations, the framework swap alone is not enough.

In the next step, you will tackle the next bottleneck: a single Node.js process can only use one CPU core. Clustering spreads the load across all available cores.

You have concrete proof that framework overhead is a real bottleneck on fast routes. Next up, you will multiply your throughput by spreading the Fastify server across every CPU core on your machine using PM2 clustering.

Add PM2 Clustering and Benchmark Again

You now have concrete proof that Fastify handles requests faster than Express. But your server is still running on a single CPU core.

Modern machines have multiple cores sitting idle. In this step, you will use PM2 to launch your Fastify server across all available CPU cores and measure how much additional throughput you gain from parallelism.

In this step, get ready to:
  • Create a PM2 ecosystem config for cluster-mode deployment.
  • Benchmark your clustered server and compare against single-process numbers.
  • Calculate the scaling factor and understand why it falls short of the theoretical maximum.
Create the PM2 ecosystem config

Node.js runs on a single thread by default. Even if your machine has 8 or 10 cores, a single node process uses only one of them. PM2's cluster mode spawns multiple copies of your server and load-balances incoming connections across all of them.

  • First, stop any running Fastify process. If you started it with node fastify-server.js, press Ctrl+C in that terminal to stop it.
  • Create a new file called ecosystem.config.cjs in your project folder by running:
touch ecosystem.config.cjs
  • Open ecosystem.config.cjs and add the following configuration:
module.exports = {
  apps: [
    {
      name: "fastify-cluster",
      script: "./fastify-server.js",
      instances: "max",
      exec_mode: "cluster",
    },
  ],
};

What does this config do?

This file tells PM2 how to run your Fastify server:

  • name gives your cluster a label for PM2's process list.
  • script points to the file PM2 should execute.
  • instances: "max" tells PM2 to auto-detect your CPU count and spawn that many worker processes.
  • exec_mode: "cluster" enables load balancing between instances. Without this, PM2 defaults to fork mode and runs a single process.
  • Save ecosystem.config.cjs.
  • Confirm the file exists by running:
ls ecosystem.config.cjs

You should see ecosystem.config.cjs listed in the output.

File not showing up?

  • Make sure you are in your project's root directory (the folder containing package.json).
  • Double check the file extension is .cjs (CommonJS), not .js. PM2 ecosystem files use module.exports which requires CommonJS format when your package.json has "type": "module".

Still stuck? Why isn't my ecosystem.config.cjs file being created in the right place?

Start the cluster and benchmark all routes

With the config file in place, you can now launch your Fastify server in cluster mode and run the same benchmarks from earlier.

  • Start the clustered server by running:
pm2 start ecosystem.config.cjs
  • Verify that PM2 launched multiple instances by running:
pm2 list

You should see a table with one row per CPU core, all showing online status and the name fastify-cluster.

What does this table mean?

Each row in the pm2 list output is one copy of your Fastify server. If you have 8 CPU cores, you will see 8 rows. PM2 automatically distributes incoming requests across all of them.

  • Now benchmark the /simple route against your clustered server by running:
autocannon -c 100 -d 10 http://localhost:3000/simple
  • Benchmark the /data route by running:
autocannon -c 100 -d 10 -m PATCH http://localhost:3000/data
  • Benchmark the /code/:id route by running:
autocannon -c 100 -d 10 http://localhost:3000/code/1
  • While a benchmark is running, open a second terminal tab and monitor CPU utilization across all instances by running:
pm2 monit

You should see each worker process consuming CPU. Press Ctrl+C to exit the monitoring view when you are done observing.

Why monitor during the benchmark?

Watching pm2 monit during a benchmark shows you whether your workload is actually spreading across cores. If only one worker is hot while others are idle, your workload might be bottlenecked on something other than CPU (like database connections or network I/O).

  • Record the Req/Sec average from each benchmark result. Write down the clustered RPS next to your single-process Fastify numbers from the previous step.

Seeing errors or very low RPS?

  • Make sure Redis is still running. The /code/:id route uses Redis caching, so if Redis is down, all requests to that route will fail.
  • Confirm no other process is using port 3000. If a previous node fastify-server.js is still running, PM2 cannot bind to the same port.
  • Try stopping everything and restarting with:
pm2 delete all
pm2 start ecosystem.config.cjs

Still having trouble? My PM2 cluster isn't starting or is showing errors when I benchmark.

Calculate the scaling factor

The most interesting metric in clustering is not the raw number. It is the ratio between clustered throughput and single-process throughput.

  • Check how many CPU cores your machine has by running:
node -e "console.log(require('os').availableParallelism())"
  • For each route, divide your clustered RPS by your single-process Fastify RPS. This is your scaling factor.
  • Compare the scaling factor against your CPU core count. For example, if you have 8 cores, the theoretical max scaling factor is 8x.

Why is the scaling less than linear?

On an 8-core machine, you will not see a full 8x improvement. Expect around 6-7x. Several factors eat into the theoretical maximum:

  • The PM2 primary process uses one core for coordinating workers.
  • Workers share the same database connection pool and Redis connection, creating contention.
  • The operating system itself needs CPU for scheduling and network handling.

You will also notice that the CPU-intensive /data route scales much better than the database-bound /code/:id route. CPU work parallelizes perfectly across cores. Database queries hit a shared bottleneck regardless of how many Node.js processes you run.

  • Update your performance progression table with the clustering numbers:
# Example format for your notes:
# Route: /simple
#   Express single:   ~XX,XXX RPS
#   Fastify single:   ~XX,XXX RPS
#   Fastify cluster:  ~XX,XXX RPS (Scaling factor: X.Xx)
#
# Route: /data
#   Express single:   ~X,XXX RPS
#   Fastify single:   ~X,XXX RPS
#   Fastify cluster:  ~XX,XXX RPS (Scaling factor: X.Xx)
#
# Route: /code/:id
#   Express single:   ~X,XXX RPS
#   Fastify single:   ~X,XXX RPS
#   Fastify cluster:  ~XX,XXX RPS (Scaling factor: X.Xx)

Key system design talking point

The lesson here is that clustering helps most for CPU-bound work. For I/O-bound work (database reads/writes), adding more processes hits diminishing returns because the bottleneck shifts to the database or network layer. This is exactly when you add caching (the next step) or scale the database itself.

✔️ Awesome, I've got everything!

Great. Double check you have saved ecosystem.config.cjs and your PM2 cluster is still running.

ⓧ I'd like to double check the full code

Here is the complete ecosystem.config.cjs file:

module.exports = {
  apps: [
    {
      name: "fastify-cluster",
      script: "./fastify-server.js",
      instances: "max",
      exec_mode: "cluster",
    },
  ],
};

Your Fastify server is now running at full multi-core capacity. Next up, you will add Redis caching to the database route and watch the final dramatic performance jump.

Add Redis Caching and Measure the Final Jump

Your clustered Fastify server is now spreading load across all CPU cores. But the biggest bottleneck on read-heavy routes is still there: every single request hits PostgreSQL.

In this step, you will benchmark the Redis cache-aside pattern that is already wired into your Fastify server. You will measure the difference between cold-cache reads (hitting Postgres) and warm-cache reads (served entirely from Redis in-memory). Then you will compile a full performance progression table documenting every optimization layer.

In this step, get ready to:
  • Understand the Redis cache-aside pattern in your Fastify server.
  • Run warm-up and benchmark passes to measure cached vs uncached performance.
  • Compile your full performance progression table with all four optimization stages.
Review the cache-aside pattern in your Fastify server

Your fastify-server.js already has a cache-aside pattern on the GET /code/:id route. Before you benchmark it, take a moment to understand how it works.

  • Open fastify-server.js and find the GET /code/:id route handler:
fastify.get("/code/:id", async () => {
  const id = Math.floor(Math.random() * 100000) + 1;
  const cacheKey = `code:${id}`;

  const cached = await redis.get(cacheKey);
  if (cached) {
    return JSON.parse(cached);
  }

  const result = await pool.query(
    "SELECT id, code FROM codes WHERE id = $1",
    [id]
  );
  const row = result.rows[0] || { error: "not found" };

  await redis.set(cacheKey, JSON.stringify(row), "EX", 60);
  return row;
});

What does this code do?

This is the cache-aside pattern (also called lazy population). It follows three steps on every request:

  • Check Redis first: redis.get(cacheKey) looks for the record in memory. If found (cache hit), the response returns immediately without touching the database.
  • Fall back to Postgres: on a cache miss, the handler queries PostgreSQL for the record.
  • Store in Redis with a TTL: redis.set(cacheKey, ..., "EX", 60) saves the result in Redis for 60 seconds. Subsequent requests for the same ID skip Postgres entirely until the TTL expires.

💡 Why cache-aside instead of write-through?

Cache-aside only stores data in Redis when it is actually requested. This avoids filling the cache with records nobody reads. For a 100,000-record table, only the frequently accessed records end up cached.

Write-through would populate Redis on every database write, wasting memory on cold data and adding latency to write operations.

Benchmark the cached read path vs the uncached path

To see the real impact of Redis caching, you need to compare two scenarios: a cold cache where every request hits Postgres, and a warm cache where most requests are served from Redis.

  • Flush the Redis cache to start with a clean slate by running this command:
redis-cli FLUSHALL

What does this command do?

FLUSHALL removes all keys from Redis. This guarantees your first benchmark run starts with zero cached data, so every request must query PostgreSQL.

  • Confirm the cache is empty by running:
redis-cli DBSIZE

You should see (integer) 0 confirming no keys exist.

  • Confirm your PM2 cluster is still running by running:
pm2 list

You should see your fastify-cluster instances listed with online status.

PM2 instances not online?

If the instances show stopped or do not appear at all, restart the cluster by running pm2 start ecosystem.config.cjs from your project folder.

Help me get my PM2 cluster running again.

  • Run the cold-cache benchmark against the /code/:id route by running:
autocannon -c 100 -d 10 http://localhost:3000/code/1

What is happening during this benchmark?

With the cache empty, the first requests all miss Redis and fall through to PostgreSQL. As the benchmark runs, it gradually populates the cache. Your final RPS number is a blend of cold and warm reads.

Record the Avg Req/Sec value from the output. This is your cold-start number for the cached route.

  • Run a dedicated warm-up pass to fill the cache with this higher-concurrency burst:
autocannon -c 200 -d 15 http://localhost:3000/code/1

This high-concurrency pass generates enough requests to cache a large portion of the 100,000 possible IDs.

  • Verify the cache is populated by running:
redis-cli DBSIZE

You should see a number in the thousands or tens of thousands, showing how many unique records are now cached in Redis.

  • Run the warm-cache benchmark with the same parameters as the cold-cache run:
autocannon -c 100 -d 10 http://localhost:3000/code/1

Record the Avg Req/Sec value. Compare this number to your cold-cache result and to your clustered benchmark from Step 4.

Why is the warm-cache number so much higher?

Redis serves data from memory with sub-millisecond latency. PostgreSQL reads involve kernel syscalls, connection pool overhead, and query parsing. By short-circuiting the database entirely on cache hits, each request completes in a fraction of the time.

🙋‍♀️ Not seeing a significant difference?

If the warm-cache number is similar to the cold-cache number, your warm-up pass may not have filled enough of the cache. Run the warm-up again with a longer duration (30 seconds) to populate more IDs.

Also check that Redis is connected. If redis-cli DBSIZE shows 0 after the warm-up, your Redis connection may have dropped.

Help me debug why my Redis cache is not improving performance.

Compile your full performance progression table

You now have benchmark data from four optimization stages. It is time to organize everything into a single progression table that tells a clear performance story.

  • Gather your recorded numbers from all previous steps. You should have RPS values for:
  • Express baseline (Step 2): /simple, /data, and /code/:id routes.
  • Fastify single-process (Step 3): same three routes.
  • Fastify clustered via PM2 (Step 4): same three routes.
  • Fastify clustered + Redis cache (this step): /code/:id warm-cache number.
  • Benchmark the other two routes one final time to complete the table. Run these two commands:
autocannon -c 100 -d 10 http://localhost:3000/simple
autocannon -c 100 -d 10 http://localhost:3000/data
  • Organize your numbers into a table like this (your numbers will differ based on your hardware):
| Route     | Express | Fastify | Clustered | + Redis Cache |
|-----------|---------|---------|-----------|---------------|
| /simple   | ???     | ???     | ???       | N/A           |
| /data     | ???     | ???     | ???       | N/A           |
| /code/:id | ???     | ???     | ???       | ???           |

How to read your progression table

Each column represents a layer of optimization. The patterns you should see:

  • Express to Fastify: largest jump on /simple because framework overhead dominates when the handler does almost nothing.
  • Single to clustered: near-linear scaling on IO-bound routes (/code/:id), less improvement on CPU-bound routes (/data) because CPU work already saturates each core.
  • Adding Redis: dramatic improvement on /code/:id because you eliminated the database round-trip entirely for cached records.
  • Document the bottleneck at each stage:
  • Stage 1 (Express baseline): framework overhead. Express parses and runs middleware on every request even when the handler is trivial.
  • Stage 2 (Fastify swap): IO-bound database reads. The framework is no longer the bottleneck, so Postgres connection latency becomes the ceiling.
  • Stage 3 (Clustering): single-core saturation. One event loop can only use one CPU core, so adding processes unlocks the other cores.
  • Stage 4 (Redis cache): disk-based reads. Even with clustering, every request still hits Postgres. Redis eliminates that round-trip for cached reads.

Why does this matter for system design?

This table gives you concrete evidence for system design discussions. Instead of saying "caching makes things faster," you can say exactly how much faster, on which routes, and why.

Interviewers and architecture reviews value this kind of measured, evidence-based reasoning over hand-wavy generalizations.

🙋‍♀️ Numbers do not match expected patterns?

If your clustered numbers are lower than single-process, PM2 might not be running all instances. Run pm2 list and confirm all instances show online status.

If your Express and Fastify numbers are close on /code/:id, that is expected. The database round-trip dominates, making framework overhead negligible in comparison.

Help me interpret my benchmark progression numbers.

You now have a documented, evidence-based performance progression showing exactly where each bottleneck sits and what solves it. Next up, you will build a React dashboard that visualizes all these numbers in real-time charts and lets you hit each route directly from the browser.

Build a React Dashboard to Visualize Your Results

You have a table of numbers proving each optimization layer works. But numbers in a terminal are hard to share and hard to feel. A chart tells the performance story instantly.

In this step, you will build a React dashboard using Vite and Recharts that visualizes your benchmark progression as a grouped bar chart, lets you hit each API route from the browser and see the response latency, and streams live request counts from the server using Server-Sent Events.

In this step, get ready to:
  • Scaffold a Vite + React dashboard app alongside your API server.
  • Add a metrics SSE endpoint to Fastify for live request counting.
  • Build a benchmark comparison chart and a live route tester component.
Scaffold the React dashboard app

Your dashboard will be a separate Vite project that talks to your Fastify server over HTTP. Keeping frontend and backend in separate folders mirrors how production apps are structured.

  • Open a new terminal tab and make sure you are inside your node-perf-benchmark project folder.
  • Scaffold a new React app inside a dashboard subfolder by running this command:
npm create vite@latest dashboard -- --template react

What does this command do?

npm create vite@latest scaffolds a new Vite project. The --template react flag selects the React JavaScript template, which comes pre-configured with JSX support and React Fast Refresh for instant browser updates as you save.

  • Move into the dashboard folder and install dependencies by running these commands:
cd dashboard
npm install
  • Install Recharts for charting by running:
npm install recharts

What is Recharts?

Recharts is a composable charting library built on React components and SVG. You build charts by combining components like BarChart, Bar, XAxis, and Tooltip declaratively, the same way you compose any React UI.

  • Configure the Vite dev server to proxy API requests to your Fastify backend. Open vite.config.js inside the dashboard folder and replace its contents with:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

// Proxy /api requests to the Fastify server on port 3000
export default defineConfig({
  plugins: [react()],
  server: {
    port: 5173,
    proxy: {
      "/api": {
        target: "http://localhost:3000",
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, ""),
      },
    },
  },
});

What does this proxy do?

When the React app fetches /api/simple, Vite rewrites it to http://localhost:3000/simple and forwards the request to your Fastify server. This avoids CORS issues during development and keeps your frontend code clean.

  • Save vite.config.js.
  • Start the Vite dev server by running:
npm run dev

You should see Local: http://localhost:5173/ printed in the terminal. Open that URL in your browser to confirm the default Vite + React page loads.

Page not loading?

Make sure nothing else is using port 5173. If another dev server is running, stop it first. Also confirm you ran npm install inside the dashboard folder, not the parent project folder.

Help me get my Vite React dashboard running

Add a metrics SSE endpoint to Fastify

For live updates, your dashboard needs a stream of data from the server. Server-Sent Events let the server push data to the browser over a single long-lived HTTP connection without WebSocket complexity.

  • Stop the Vite dev server with Ctrl+C (you will restart it later).
  • Navigate back to your project root by running:
cd ..
  • Open fastify-server.js and add this metrics tracking code right after the const fastify = Fastify({ logger: false }); line:
// Track request counts per route for live metrics
let metrics = { simple: 0, data: 0, code_read: 0, code_write: 0 };

fastify.addHook("onResponse", (request, reply, done) => {
  if (request.url === "/simple") metrics.simple++;
  else if (request.url === "/data") metrics.data++;
  else if (request.url.startsWith("/code/")) metrics.code_read++;
  else if (request.url === "/code" && request.method === "POST") metrics.code_write++;
  done();
});

// SSE endpoint for live dashboard updates
fastify.get("/metrics/stream", async (request, reply) => {
  reply.raw.writeHead(200, {
    "Content-Type": "text/event-stream",
    "Cache-Control": "no-cache",
    Connection: "keep-alive",
    "Access-Control-Allow-Origin": "*",
  });

  const interval = setInterval(() => {
    const payload = JSON.stringify(metrics);
    reply.raw.write(`data: ${payload}\n\n`);
  }, 1000);

  request.raw.on("close", () => {
    clearInterval(interval);
  });
});

What does this code do?

The onResponse hook increments a counter every time a route responds. The /metrics/stream endpoint opens an SSE connection and pushes the current counts every second.

The browser's built-in EventSource API connects to this stream and receives updates automatically. No polling, no WebSocket setup, no extra libraries.

  • Save fastify-server.js.
  • Restart your PM2 cluster to pick up the changes by running:
pm2 restart all

You should see all instances restart with online status.

  • Verify the SSE endpoint works by running:
curl -N http://localhost:3000/metrics/stream

You should see data: {"simple":0,"data":0,"code_read":0,"code_write":0} printed every second. Press Ctrl+C to stop.

No output from curl?

Make sure you used the -N flag (disables buffering). Also confirm PM2 restarted successfully with pm2 list. If instances show errored, check your code changes with pm2 logs.

Help me debug my metrics SSE endpoint

Build the benchmark chart and route tester components

Now you will replace the default Vite app with two components: a grouped bar chart showing your benchmark progression, and a route tester that hits each API endpoint and measures response time.

  • Move back into the dashboard folder by running:
cd dashboard
  • Create a new file called src/BenchmarkChart.jsx and add the following code:
import {
  BarChart,
  Bar,
  XAxis,
  YAxis,
  CartesianGrid,
  Tooltip,
  Legend,
  ResponsiveContainer,
} from "recharts";

// Replace these with YOUR actual benchmark numbers
const data = [
  { route: "/simple", Express: 18000, Fastify: 85000, Clustered: 450000, Cached: null },
  { route: "/data", Express: 8000, Fastify: 22000, Clustered: 120000, Cached: null },
  { route: "/code/:id", Express: 12000, Fastify: 15000, Clustered: 60000, Cached: 200000 },
];

export default function BenchmarkChart() {
  return (
    <div style={{ width: "100%", height: 400 }}>
      <h2>Benchmark Progression (Requests/sec)</h2>
      <ResponsiveContainer>
        <BarChart data={data} margin={{ top: 20, right: 30, left: 40, bottom: 5 }}>
          <CartesianGrid strokeDasharray="3 3" />
          <XAxis dataKey="route" />
          <YAxis />
          <Tooltip formatter={(value) => value ? value.toLocaleString() : "N/A"} />
          <Legend />
          <Bar dataKey="Express" fill="#ef4444" />
          <Bar dataKey="Fastify" fill="#3b82f6" />
          <Bar dataKey="Clustered" fill="#8b5cf6" />
          <Bar dataKey="Cached" fill="#10b981" />
        </BarChart>
      </ResponsiveContainer>
    </div>
  );
}

What does this component do?

This renders a grouped bar chart with one group per route and one bar per optimization stage. ResponsiveContainer makes the chart resize with the browser window. The data array holds your actual RPS numbers from the terminal benchmarks.

Replace the placeholder numbers with your real results from Steps 2-5. The chart instantly shows the performance story: red (Express) is always shortest, green (Cached) is tallest on the /code/:id route.

  • Save src/BenchmarkChart.jsx.
  • Create a new file called src/RouteTester.jsx and add:
import { useState } from "react";

const ROUTES = [
  { label: "GET /simple", path: "/api/simple", method: "GET" },
  { label: "PATCH /data", path: "/api/data", method: "PATCH" },
  { label: "GET /code/:id", path: "/api/code/1", method: "GET" },
  { label: "POST /code", path: "/api/code", method: "POST" },
];

export default function RouteTester() {
  const [results, setResults] = useState([]);

  async function hitRoute(route) {
    const start = performance.now();
    try {
      const res = await fetch(route.path, { method: route.method });
      const json = await res.json();
      const latency = (performance.now() - start).toFixed(1);
      setResults((prev) => [
        { label: route.label, latency, status: res.status, preview: JSON.stringify(json).slice(0, 120) },
        ...prev.slice(0, 19),
      ]);
    } catch (err) {
      const latency = (performance.now() - start).toFixed(1);
      setResults((prev) => [
        { label: route.label, latency, status: "ERR", preview: err.message },
        ...prev.slice(0, 19),
      ]);
    }
  }

  return (
    <div>
      <h2>Route Tester</h2>
      <div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
        {ROUTES.map((r) => (
          <button key={r.label} onClick={() => hitRoute(r)}>
            {r.label}
          </button>
        ))}
      </div>
      <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 14 }}>
        <thead>
          <tr>
            <th style={{ textAlign: "left" }}>Route</th>
            <th>Status</th>
            <th>Latency</th>
            <th style={{ textAlign: "left" }}>Response Preview</th>
          </tr>
        </thead>
        <tbody>
          {results.map((r, i) => (
            <tr key={i} style={{ borderTop: "1px solid #eee" }}>
              <td>{r.label}</td>
              <td style={{ textAlign: "center" }}>{r.status}</td>
              <td style={{ textAlign: "center" }}>{r.latency}ms</td>
              <td style={{ fontFamily: "monospace", fontSize: 12 }}>{r.preview}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

What does this component do?

Each button fires a fetch request to your Fastify API through the Vite proxy. It measures the round-trip time using performance.now() and displays the status code, latency, and a truncated response preview. Results stack in reverse-chronological order so the latest is always on top.

  • Save src/RouteTester.jsx.
  • Create a new file called src/LiveMetrics.jsx and add:
import { useState, useEffect } from "react";

export default function LiveMetrics() {
  const [metrics, setMetrics] = useState({ simple: 0, data: 0, code_read: 0, code_write: 0 });

  useEffect(() => {
    // Connect to the SSE stream from Fastify
    const source = new EventSource("/api/metrics/stream");
    source.onmessage = (event) => {
      setMetrics(JSON.parse(event.data));
    };
    return () => source.close();
  }, []);

  return (
    <div>
      <h2>Live Request Counts</h2>
      <div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 16 }}>
        <div style={{ padding: 16, background: "#fef2f2", borderRadius: 8 }}>
          <div style={{ fontSize: 32, fontWeight: "bold" }}>{metrics.simple.toLocaleString()}</div>
          <div>/simple</div>
        </div>
        <div style={{ padding: 16, background: "#eff6ff", borderRadius: 8 }}>
          <div style={{ fontSize: 32, fontWeight: "bold" }}>{metrics.data.toLocaleString()}</div>
          <div>/data</div>
        </div>
        <div style={{ padding: 16, background: "#f5f3ff", borderRadius: 8 }}>
          <div style={{ fontSize: 32, fontWeight: "bold" }}>{metrics.code_read.toLocaleString()}</div>
          <div>/code/:id (read)</div>
        </div>
        <div style={{ padding: 16, background: "#ecfdf5", borderRadius: 8 }}>
          <div style={{ fontSize: 32, fontWeight: "bold" }}>{metrics.code_write.toLocaleString()}</div>
          <div>/code (write)</div>
        </div>
      </div>
    </div>
  );
}

What does this component do?

The useEffect hook opens an EventSource connection to /api/metrics/stream. Every second, the server pushes updated request counts, and the component re-renders with the new numbers. When the component unmounts, source.close() cleans up the connection.

Run an autocannon benchmark in a separate terminal and watch the counters climb in real time.

  • Save src/LiveMetrics.jsx.
  • Now wire everything together. Open src/App.jsx and replace its entire contents with:
import BenchmarkChart from "./BenchmarkChart";
import RouteTester from "./RouteTester";
import LiveMetrics from "./LiveMetrics";

export default function App() {
  return (
    <div style={{ maxWidth: 960, margin: "0 auto", padding: 32, fontFamily: "system-ui" }}>
      <h1>Node.js Performance Dashboard</h1>
      <BenchmarkChart />
      <hr style={{ margin: "32px 0" }} />
      <LiveMetrics />
      <hr style={{ margin: "32px 0" }} />
      <RouteTester />
    </div>
  );
}
  • Save src/App.jsx.
  • Delete the default CSS files that conflict with your layout by running:
rm src/App.css src/index.css
  • Open src/main.jsx and remove the CSS import line. The file should look like:
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";

createRoot(document.getElementById("root")).render(
  <StrictMode>
    <App />
  </StrictMode>
);
  • Save src/main.jsx.
  • Start the Vite dev server again by running:
npm run dev

Open http://localhost:5173 in your browser. You should see three sections: the benchmark bar chart at the top, the live metrics counters in the middle, and the route tester buttons at the bottom.

  • Click each route tester button. You should see results appear in the table with latency numbers and JSON response previews.
  • Open a separate terminal and run a quick benchmark to see the live counters move:
autocannon -c 10 -d 5 http://localhost:3000/simple

Watch the /simple counter in your dashboard climb in real time as autocannon fires requests.

Chart not rendering or buttons not working?

If the chart shows nothing, confirm the data array in BenchmarkChart.jsx has numbers (not all zeros). If buttons return errors, confirm your PM2 cluster is running on port 3000 with pm2 list.

Check the browser DevTools console for network errors. The Vite proxy only works when the dev server is running.

Help me fix my React dashboard

💡 Why does this dashboard matter for system design?

In production, observability dashboards (Grafana, Datadog) do exactly what you just built: stream metrics from servers and render them as time-series charts. Building one from scratch teaches you the fundamentals that those tools abstract away. Server-Sent Events, metric counters per route, and client-side rendering of live data.

You now have a visual layer on top of your performance data. The grouped bar chart makes the optimization story obvious at a glance, and the live metrics prove the system is real, not just numbers on a screen. Next up: the Secret Mission, where you will tackle the write side of performance with a Redis write buffer.

Secret mission

Redis Write Buffer with Async Postgres Sync

Companies like Uber handle millions of writes per second without melting their databases. The trick? Write to Redis first, sync to Postgres in the background. In this mission, you'll implement that exact pattern and benchmark the throughput difference against direct database writes.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • PostgreSQL benchmark database with the codes table (free, local).
  • Redis running locally (free, local).
  • PM2 processes managing the Fastify cluster.
  • Global npm packages: autocannon and pm2.
  • Project folder node-perf-benchmark on your Desktop.

Keep everything running

No action needed. Choose this if you're still actively experimenting with your benchmark setup or want to try new routes and optimization strategies.

  • Redis and PostgreSQL will continue running in the background.
  • PM2 processes will persist across terminal sessions.
  • The global autocannon and pm2 packages remain available for future projects.

Pause - I'll come back to this later

Stop running processes to free up memory while keeping all your files and data intact.

  • Stop PM2 processes by running:
  • Stop Redis by pressing Ctrl+C in its terminal window, or run:
pm2 delete all
brew services stop redis

PostgreSQL can stay running with minimal resource usage. Your project folder and database remain ready for when you come back.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below.

  • Stop and remove all PM2 processes:
pm2 delete all
  • Drop the PostgreSQL benchmark database:
dropdb benchmark
  • Stop Redis and uninstall it:
brew services stop redis
brew uninstall redis
  • Uninstall the global npm packages:
npm uninstall -g autocannon pm2
  • Delete the project folder:
rm -rf ~/Desktop/node-perf-benchmark

That's a Wrap!

That's a Wrap!

Nice work! You just built a high-performance Node.js API and systematically benchmarked every optimization layer with real numbers from your own hardware.

You now have concrete evidence for every performance claim you make in system design conversations.

You've learned how to:

  • Benchmark Express vs Fastify with autocannon and measure the throughput difference that framework overhead creates on identical routes.
  • Deploy a PM2 cluster across all CPU cores and calculate the real scaling factor against the theoretical maximum.
  • Implement a Redis cache-aside pattern that shifts reads from disk-based PostgreSQL to in-memory lookups, dramatically reducing response times.
  • Secret Mission: Built a Redis write buffer with an async sync worker that batches writes to PostgreSQL in the background, replicating the pattern used by companies handling millions of writes per second.

Ready to quiz yourself?