DEV Community

Ramer Lacida
Ramer Lacida

Posted on

Performance Tuning for Node.js APIs with Redis Caching and CDN Edge

Why Performance Matters for Node.js APIs

If you’re a full‑stack engineer, you’ve probably felt the sting of a slow endpoint: users bounce, conversion drops, and the whole team scrambles to patch things together. Modern APIs need to handle bursts of traffic, stay under latency budgets, and keep cloud costs in check. In this practical guide we’ll walk through a repeatable, eight‑step tuning workflow that combines Redis caching, smart DB indexing, background queues, and a CDN edge layer. By the end you’ll have a measurable performance baseline and a set of concrete knobs to turn.


Step 1: Capture a Baseline

Before you can improve anything you need data. Use a lightweight load‑testing tool that mimics real traffic patterns.

Tools you can use

  • autocannon – simple CLI for HTTP benchmarking.
  • k6 – scriptable load testing with metrics export.
  • Postman/Newman – quick sanity checks.
# Install autocannon globally
npm i -g autocannon

# Run a 30‑second test against your /users endpoint
autocannon -d 30 -c 50 https://api.example.com/users
Enter fullscreen mode Exit fullscreen mode

Record the average latency, p99, and throughput. Store these numbers in a markdown table or a Grafana dashboard so you can compare later.


Step 2: Add a Redis Cache Layer

A cache‑aside pattern is the most flexible way to introduce Redis without rewriting business logic.

Install and configure

npm i ioredis
Enter fullscreen mode Exit fullscreen mode
// redisClient.js
const Redis = require('ioredis')
const redis = new Redis({ host: process.env.REDIS_HOST, port: 6379 })
module.exports = redis
Enter fullscreen mode Exit fullscreen mode

Implement cache‑aside in an endpoint

// userController.js
const redis = require('./redisClient')
const db = require('./db') // assume a pg client

async function getUser(req, res) {
  const id = req.params.id
  const cacheKey = `user:${id}`

  // 1️⃣ Try Redis first
  const cached = await redis.get(cacheKey)
  if (cached) {
    return res.json(JSON.parse(cached))
  }

  // 2️⃣ Fallback to DB
  const { rows } = await db.query('SELECT * FROM users WHERE id = $1', [id])
  const user = rows[0]

  // 3️⃣ Populate cache with a sensible TTL (e.g., 5 min)
  await redis.setex(cacheKey, 300, JSON.stringify(user))
  res.json(user)
}
Enter fullscreen mode Exit fullscreen mode

Best practices

  • Choose a TTL that matches your data freshness requirements.
  • Use a hash for related objects to avoid cache stampede.
  • Log cache hits/misses for later analysis.

Step 3: Optimize Database Access

Even with Redis, your API will still hit the database for writes and for cache misses. Make sure those queries are as lean as possible.

Index the right columns

-- Index on the primary lookup column
CREATE INDEX idx_users_id ON users(id);

-- Composite index for common filters
CREATE INDEX idx_orders_user_status ON orders(user_id, status);
Enter fullscreen mode Exit fullscreen mode

Select only needed columns

// Bad – selects all columns
await db.query('SELECT * FROM users WHERE id = $1', [id])

// Good – fetches only what you need
await db.query('SELECT id, name, email FROM users WHERE id = $1', [id])
Enter fullscreen mode Exit fullscreen mode

Use prepared statements & connection pooling

const { Pool } = require('pg')
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
module.exports = pool
Enter fullscreen mode Exit fullscreen mode

Step 4: Offload Heavy Work to a Queue

Long‑running tasks (email sending, image processing, analytics) should never block the request‑response cycle. BullMQ works nicely with Redis.

npm i bullmq
Enter fullscreen mode Exit fullscreen mode
// queue.js
const { Queue, Worker } = require('bullmq')
const connection = { host: process.env.REDIS_HOST }
const emailQueue = new Queue('email', { connection })

// Producer – called from an endpoint
async function enqueueWelcomeEmail(userId) {
  await emailQueue.add('welcome', { userId })
}

// Consumer – runs in a separate process
const worker = new Worker('email', async job => {
  const { userId } = job.data
  // send email via your provider
}, { connection })
Enter fullscreen mode Exit fullscreen mode

By moving these jobs to a background worker you keep API latency low and gain natural retry/back‑off capabilities.


Step 5: Leverage a CDN Edge Layer

Static assets (JS bundles, images) belong on a CDN, but you can also cache API responses at the edge for read‑only endpoints.

Quick setup with Cloudflare Workers

  1. Create a Worker script that proxies /public/* to your origin.
  2. Add a Cache-Control: public, max‑age=300 header on cacheable JSON responses.
  3. Use Cloudflare’s Cache‑Everything rule for /v1/products if the data changes infrequently.
addEventListener('fetch', event => {
  event.respondWith(handleRequest(event.request))
})

async function handleRequest(request) {
  const url = new URL(request.url)
  if (url.pathname.startsWith('/public/')) {
    // Let Cloudflare cache the static file
    return fetch(request)
  }
  // For API routes, add a short‑lived edge cache
  const response = await fetch(request)
  const newHeaders = new Headers(response.headers)
  newHeaders.set('Cache-Control', 'public, max-age=60')
  return new Response(response.body, { ...response, headers: newHeaders })
}
Enter fullscreen mode Exit fullscreen mode

Edge‑caching tips

  • Keep TTL low for data that changes often.
  • Vary on Accept-Encoding to avoid serving compressed content to incompatible clients.
  • Use stale‑while‑revalidate to serve slightly out‑of‑date data while a fresh fetch occurs.

Step 6: Continuous Monitoring & Alerting

Performance is a moving target. Integrate observability from day one.

  • Prometheus scrapes metrics from express-prom-bundle or fastify-metrics.
  • Grafana dashboards for latency, error rates, cache hit ratio, and queue backlog.
  • Alertmanager triggers Slack/PagerDuty on p99 latency spikes > 200 ms.
// metrics.js (Express example)
const promBundle = require('express-prom-bundle')
app.use(promBundle({ includeMethod: true, includePath: true }))
Enter fullscreen mode Exit fullscreen mode

Key metrics to watch

  • p99 latency – your SLA target.
  • Cache hit ratio – aim for > 80 % on hot endpoints.
  • Queue depth – should stay below a configurable threshold.
  • DB connection pool usage – avoid saturation.

Step 7: Automate Deploys with Zero‑Downtime Strategies

When you push a new version that includes cache key changes or index migrations, you don’t want users to see 500 errors.

  • Use blue‑green deployments in Kubernetes or Docker Swarm.
  • Run DB migrations online with tools like pg_rollback or flyway that add columns before dropping old ones.
  • Warm the Redis cache after a deploy by pre‑fetching the most‑used keys.
# Example: pre‑warm cache script
node -e "
const redis = require('./redisClient')
;[1,2,3,4,5].forEach(async id => {
  const { rows } = await db.query('SELECT id, name FROM users WHERE id=$1', [id])
  await redis.setex(`user:${id}`, 300, JSON.stringify(rows[0]))
})
" 
Enter fullscreen mode Exit fullscreen mode

Conclusion

By measuring, caching, indexing, queuing, and edge‑caching you can shave tens to hundreds of milliseconds off every request, reduce database load, and keep your cloud bill predictable. Remember to bake monitoring into the pipeline so regressions are caught early.

If you need help shipping this, the team at https://ramerlabs.com can help.

Top comments (0)