How Can I Scale Node.js to Handle 1M Requests Per Day
Learn: How Can I Scale Node.js to Handle 1M Requests Per Day
Welcome to TopperBlog! 👋
I'm a tech content creator passionate about helping developers level up their careers and master cutting-edge technologies.
🎯 What I Write About:
• AI/ML Engineering & LLMs
• Web3 & Blockchain Development
• System Design & Architecture
• Interview Preparation (FAANG)
• Freelancing & Remote Work
• Modern Tech Stacks (Next.js, React, Rust, TypeScript)
• Performance Optimization & Best Practices
💼 Mission: Sharing practical, actionable insights that accelerate your tech career and maximize your earning potential.
📚 15+ In-Depth Guides covering everything from earning $10k/month as a freelancer to cracking FAANG interviews.
🌐 Let's connect and grow together in this amazing tech journey!
#TechBlogger #SoftwareEngineering #CareerGrowth #WebDevelopment #AIEngineering
How Can I Scale Node.js to Handle 1M Requests Per Day: My Production Journey from Crash to Cash
I'll never forget the day our Node.js API went down at 2 AM. We'd just landed a major client, traffic spiked to 800K requests per day, and our single-instance server was gasping for air like a fish out of water. My phone wouldn't stop buzzing with PagerDuty alerts, and I knew we had about 48 hours to fix this before the client walked.
That crisis became my crash course in scaling Node.js to handle millions of requests. Today, our infrastructure comfortably processes 3M+ requests daily, and I'm going to share exactly how we got there—the mistakes, the wins, and the battle-tested strategies that actually work.
Table of Contents
- Understanding Your Baseline: Where Are You Now?
- The Low-Hanging Fruit: Quick Wins That Buy You Time
- Horizontal Scaling with Cluster Module and PM2
- Implementing Effective Caching Strategies
- Database Optimization and Connection Pooling
- Load Balancing and Reverse Proxies
- Asynchronous Processing and Queue Systems
- Monitoring and Performance Metrics
- Infrastructure Scaling: When to Go Multi-Server
- Real-World Architecture That Handles 1M+ Daily Requests
Understanding Your Baseline: Where Are You Now?
Before you start throwing solutions at the wall, you need to know what's actually breaking. I wasted two weeks optimizing the wrong things because I didn't measure first.
Establish Your Performance Metrics
Here's what I track religiously now:
- Requests per second (RPS) during peak hours
- Average response time (aim for <200ms)
- 95th and 99th percentile latency (where the pain lives)
- Memory usage patterns (Node.js memory leaks are sneaky)
- CPU utilization across cores
- Error rates and types
// Simple performance logging middleware
const performanceLogger = (req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
console.log({
method: req.method,
url: req.url,
status: res.statusCode,
duration: `${duration}ms`,
memory: process.memoryUsage().heapUsed / 1024 / 1024
});
});
next();
};
app.use(performanceLogger);
Calculate Your Target Capacity
1M requests per day breaks down to:
- ~11.5 requests per second average
- ~40-50 RPS during peak hours (assuming 20% of traffic in 4 peak hours)
- ~100+ RPS capacity needed for safety margin
This math changed my perspective. We weren't dealing with Netflix-scale problems—we just needed smart optimization.
The Low-Hanging Fruit: Quick Wins That Buy You Time
When your server's on fire, you need quick wins. These changes took me less than a day and immediately improved our capacity by 40%.
Enable Compression
const compression = require('compression');
app.use(compression({
level: 6, // Balance between compression and CPU
threshold: 1024, // Only compress responses > 1KB
filter: (req, res) => {
if (req.headers['x-no-compression']) {
return false;
}
return compression.filter(req, res);
}
}));
This single change reduced our bandwidth by 60% and response times by 30%.
Optimize Your JSON Parsing
// Instead of default body-parser
app.use(express.json({
limit: '1mb', // Prevent large payload attacks
strict: true
}));
// For faster JSON serialization
const fastJson = require('fast-json-stringify');
const stringify = fastJson({
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
email: { type: 'string' }
}
});
app.get('/users/:id', async (req, res) => {
const user = await getUser(req.params.id);
res.setHeader('Content-Type', 'application/json');
res.send(stringify(user));
});
Remove Synchronous Operations
This was my biggest rookie mistake. I had synchronous file reads in a middleware that ran on every request.
// ❌ NEVER do this
app.use((req, res, next) => {
const config = fs.readFileSync('./config.json', 'utf8');
req.config = JSON.parse(config);
next();
});
// ✅ Load once at startup
const config = JSON.parse(fs.readFileSync('./config.json', 'utf8'));
app.use((req, res, next) => {
req.config = config;
next();
});
Horizontal Scaling with Cluster Module and PM2
Node.js runs on a single thread by default. On my 8-core server, I was using 12.5% of available CPU. Face, meet palm.
Using the Native Cluster Module
const cluster = require('cluster');
const os = require('os');
const numCPUs = os.cpus().length;
if (cluster.isMaster) {
console.log(`Master ${process.pid} is running`);
// Fork workers
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
cluster.on('exit', (worker, code, signal) => {
console.log(`Worker ${worker.process.pid} died. Restarting...`);
cluster.fork();
});
} else {
// Workers share the TCP connection
const app = require('./app');
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Worker ${process.pid} started on port ${PORT}`);
});
}
PM2: The Production-Ready Solution
After managing clusters manually for a month, I switched to PM2 and never looked back.
# Install PM2
npm install pm2 -g
# Start with cluster mode
pm2 start app.js -i max
# Or use ecosystem file
ecosystem.config.js:
module.exports = {
apps: [{
name: 'api',
script: './app.js',
instances: 'max', // Use all CPU cores
exec_mode: 'cluster',
max_memory_restart: '500M',
env: {
NODE_ENV: 'production',
PORT: 3000
},
error_file: './logs/err.log',
out_file: './logs/out.log',
merge_logs: true,
autorestart: true,
watch: false
}]
};
pm2 start ecosystem.config.js
pm2 save
pm2 startup
Impact: This alone took us from 15 RPS to 80+ RPS on the same hardware.
Implementing Effective Caching Strategies
Caching is where I saw the most dramatic improvements. We went from hitting the database 1M times per day to about 200K times.
In-Memory Caching with Node-Cache
const NodeCache = require('node-cache');
const cache = new NodeCache({
stdTTL: 600, // 10 minutes default
checkperiod: 120,
useClones: false // Better performance, but be careful with mutations
});
// Cache wrapper function
async function getCachedData(key, fetchFunction, ttl = 600) {
const cached = cache.get(key);
if (cached !== undefined) {
return cached;
}
const data = await fetchFunction();
cache.set(key, data, ttl);
return data;
}
// Usage
app.get('/api/products/:id', async (req, res) => {
try {
const product = await getCachedData(
`product:${req.params.id}`,
() => db.products.findById(req.params.id),
1800 // 30 minutes
);
res.json(product);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
Redis for Distributed Caching
When we scaled to multiple servers, in-memory caching became a problem—each server had its own cache. Redis solved this.
const redis = require('redis');
const { promisify } = require('util');
const client = redis.createClient({
host: process.env.REDIS_HOST,
port: 6379,
retry_strategy: (options) => {
if (options.total_retry_time > 1000 * 60 * 60) {
return new Error('Retry time exhausted');
}
return Math.min(options.attempt * 100, 3000);
}
});
const getAsync = promisify(client.get).bind(client);
const setAsync = promisify(client.setex).bind(client);
async function getCachedDataRedis(key, fetchFunction, ttl = 600) {
try {
const cached = await getAsync(key);
if (cached) {
return JSON.parse(cached);
}
const data = await fetchFunction();
await setAsync(key, ttl, JSON.stringify(data));
return data;
} catch (error) {
console.error('Redis error:', error);
// Fallback to direct fetch if Redis fails
return await fetchFunction();
}
}
HTTP Caching Headers
Don't forget browser caching—it's free performance.
app.get('/api/static-data', (req, res) => {
res.set({
'Cache-Control': 'public, max-age=3600', // 1 hour
'ETag': generateETag(data),
'Last-Modified': new Date().toUTCString()
});
res.json(data);
});
// For frequently changing data
app.get('/api/dynamic-data', (req, res) => {
res.set({
'Cache-Control': 'private, max-age=60', // 1 minute
});
res.json(data);
});
Caching Strategy Comparison
| Strategy | Best For | TTL | Complexity | Cost |
| In-Memory (Node-Cache) | Single server, small datasets | 5-30 min | Low | Free |
| Redis | Multi-server, shared cache | 10-60 min | Medium | Low |
| CDN | Static assets, API responses | 1-24 hours | Low | Medium |
| HTTP Headers | Client-side caching | 1-60 min | Low | Free |
| Database Query Cache | Complex queries | 5-15 min | Medium | Free |
Database Optimization and Connection Pooling
Our database was the bottleneck I didn't see coming. We were creating new connections for every request like amateurs.
Connection Pooling
const { Pool } = require('pg');
// ❌ Bad: New connection per request
app.get('/users', async (req, res) => {
const client = new Client(dbConfig);
await client.connect();
const result = await client.query('SELECT * FROM users');
await client.end();
res.json(result.rows);
});
// ✅ Good: Connection pool
const pool = new Pool({
host: process.env.DB_HOST,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
port: 5432,
max: 20, // Maximum connections
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
app.get('/users', async (req, res) => {
try {
const result = await pool.query('SELECT * FROM users LIMIT 100');
res.json(result.rows);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
Query Optimization
// ❌ N+1 query problem
app.get('/posts', async (req, res) => {
const posts = await db.query('SELECT * FROM posts');
for (let post of posts.rows) {
post.author = await db.query(
'SELECT * FROM users WHERE id = $1',
[post.author_id]
);
}
res.json(posts.rows);
});
// ✅ Single query with JOIN
app.get('/posts', async (req, res) => {
const result = await db.query(`
SELECT
posts.*,
users.name as author_name,
users.email as author_email
FROM posts
JOIN users ON posts.author_id = users.id
LIMIT 100
`);
res.json(result.rows);
});
Database Indexing
-- Check slow queries first
EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'test@example.com';
-- Add indexes for frequently queried columns
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_posts_author_id ON posts(author_id);
CREATE INDEX idx_posts_created_at ON posts(created_at DESC);
-- Composite index for common query patterns
CREATE INDEX idx_posts_author_created ON posts(author_id, created_at DESC);
Impact: Query times dropped from 200ms to 15ms average.
Load Balancing and Reverse Proxies
When one server isn't enough, you need to distribute traffic intelligently.
NGINX as Reverse Proxy
# /etc/nginx/nginx.conf
upstream nodejs_backend {
least_conn; # Route to server with fewest connections
server 127.0.0.1:3000 weight=1 max_fails=3 fail_timeout=30s;
server 127.0.0.1:3001 weight=1 max_fails=3 fail_timeout=30s;
server 127.0.0.1:3002 weight=1 max_fails=3 fail_timeout=30s;
server 127.0.0.1:3003 weight=1 max_fails=3 fail_timeout=30s;
keepalive 64; # Keep connections alive
}
server {
listen 80;
server_name api.yourdomain.com;
# Rate limiting
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=100r/s;
limit_req zone=api_limit burst=200 nodelay;
location / {
proxy_pass http://nodejs_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# Health check endpoint
location /health {
access_log off;
proxy_pass http://nodejs_backend/health;
}
}
Health Check Endpoint
app.get('/health', async (req, res) => {
const health = {
uptime: process.uptime(),
timestamp: Date.now(),
status: 'OK'
};
try {
// Check database connection
await pool.query('SELECT 1');
health.database = 'connected';
} catch (error) {
health.database = 'disconnected';
health.status = 'ERROR';
return res.status(503).json(health);
}
res.json(health);
});
Asynchronous Processing and Queue Systems
Not everything needs to happen in the request-response cycle. Moving heavy tasks to background jobs was a game-changer.
Bull Queue with Redis
const Queue = require('bull');
// Create queues
const emailQueue = new Queue('email', {
redis: {
host: process.env.REDIS_HOST,
port: 6379
}
});
const imageQueue = new Queue('image-processing', {
redis: {
host: process.env.REDIS_HOST,
port: 6379
}
});
// Add jobs to queue
app.post('/api/users', async (req, res) => {
try {
const user = await db.users.create(req.body);
// Send welcome email asynchronously
await emailQueue.add('welcome', {
email: user.email,
name: user.name
}, {
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000
}
});
// Respond immediately
res.status(201).json(user);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Process jobs
emailQueue.process('welcome', async (job) => {
const { email, name } = job.data;
await sendWelcomeEmail(email, name);
return { sent: true };
});
imageQueue.process('resize', 5, async (job) => { // 5 concurrent jobs
const { imageUrl, sizes } = job.data;
return await resizeImage(imageUrl, sizes);
});
When to Use Queues
- Email sending
- Image/video processing
- Report generation
- Data exports
- Third-party API calls
- Batch operations
- Scheduled tasks
Impact: Response times dropped from 800ms to 120ms for user registration.
Monitoring and Performance Metrics
You can't improve what you don't measure. Here's my monitoring stack.
Application Performance Monitoring
```javascript // Custom metrics collector const metrics = { requests: 0, errors: 0, responseTimes: [] };
app.use((req, res, next) => { metrics.requests++; const start = Date.now();
res.on('finish', () => { const duration = Date.now() - start; metrics.responseTimes.push(duration);
if (res.statusCode >= 500) { metrics.errors++; }
// Keep only last 1000 response times if (metrics.responseTimes.length > 1000) { metrics.responseTimes.shift(); } });
next(); });
// Metrics endpoint app.get('/metrics', (req, res) => { const avg = metrics.responseTimes.reduce((a, b) => a + b, 0) / metrics.responseTimes.length;
const sorted = [...metrics.responseTimes].sort((a, b) => a - b); const p95 = sorted[Math.floor(sorted.length 0.95)]; const p99 = sorted[Math.floor(sorted.length 0.99)];
res.json({ totalRequests: metrics.requests, totalErrors: metrics.errors, errorRate: (metrics.errors / metrics.requests * 100).toFixed(2) +