How I Cut API Response Time by 87% in Node.js (5-Step Fix)
1.2 seconds per request. Thousands of daily users. No bug reports — but users were feeling it. Here's the exact five-step process I used to cut response times by 87% without a rewrite.
When I started optimizing backend code, one of the first things I noticed was that our APIs were slow. Not broken — just slow. Averaging 1.2 seconds per request across 15+ endpoints. For a messaging platform handling thousands of requests daily, that's the kind of slowness users feel.
No one had filed a bug report. But slow APIs are a silent killer — they hurt retention, increase server costs, and make your system fragile under load.
So I fixed it. Here's exactly how.
import { Injectable, NestMiddleware } from "@nestjs/common";
import { Request, Response, NextFunction } from "express";
@Injectable()
export class ResponseTimeMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
const start = Date.now();
res.on("finish", () => {
const duration = Date.now() - start;
console.log(`${req.method} ${req.originalUrl} → ${duration}ms`);
});
next();
}
}
After logging every endpoint for 48 hours, a clear pattern emerged:
| Endpoint | Avg Response Time | |
|---|---|---|
GET /campaigns | 1,400ms | |
GET /messages | 1,100ms | |
POST /send-bulk | 1,800ms | |
GET /analytics | 1,300ms | |
GET /contacts | 950ms |
All slow. All for different reasons.
Problem 1 — N+1 Queries
This was the biggest culprit. We were fetching a list of campaigns, then for each campaign making a separate database call to fetch its message count.
// ❌ Before — N+1 problem
async getCampaigns() {
const campaigns = await this.prisma.campaign.findMany();
// This runs a separate query for EVERY campaign
return Promise.all(
campaigns.map(async (campaign) => ({
...campaign,
messageCount: await this.prisma.message.count({
where: { campaignId: campaign.id },
}),
}))
);
}
For 50 campaigns this fired 51 database queries per request. No wonder it was slow.
The fix — use Prisma's _count to get everything in one query:
// ✅ After — single query
async getCampaigns() {
return this.prisma.campaign.findMany({
include: {
_count: {
select: { messages: true },
},
},
});
}
Result: GET /campaigns dropped from 1,400ms to 320ms.
Problem 2 — Missing Database Indexes
Our messages table had 500,000+ rows. Every query filtering by campaignId or status was doing a full table scan.
-- Check which queries are slow
EXPLAIN ANALYZE
SELECT * FROM messages
WHERE campaign_id = 'abc123'
AND status = 'delivered';
Output showed Seq Scan — sequential scan across the entire table. That is as bad as it sounds.
The fix:
-- Add composite index for the most common query pattern
CREATE INDEX idx_messages_campaign_status
ON messages(campaign_id, status);
-- Add index for timestamp-based sorting
CREATE INDEX idx_messages_created_at
ON messages(created_at DESC);
In Prisma schema:
model Message {
id String @id @default(cuid())
campaignId String
status String
createdAt DateTime @default(now())
@@index([campaignId, status])
@@index([createdAt(sort: Desc)])
}
Result: GET /messages dropped from 1,100ms to 180ms.
Problem 3 — Synchronous Operations Blocking the Event Loop
Our bulk message sending endpoint was processing everything synchronously — validating, formatting, and sending each message one by one before returning a response.
// ❌ Before — blocking the response
async sendBulkMessages(dto: BulkMessageDto) {
const results = [];
for (const contact of dto.contacts) {
const message = await this.formatMessage(contact, dto.template);
const result = await this.whatsappService.send(message);
results.push(result);
}
return { sent: results.length };
}
For 500 contacts this took 1,800ms+ and blocked the entire thread.
The fix — accept the request immediately, process via BullMQ queue:
// ✅ After — async queue processing
@Injectable()
export class MessageService {
constructor(@InjectQueue("messages") private messageQueue: Queue) {}
async sendBulkMessages(dto: BulkMessageDto) {
// Add to queue and return immediately
await this.messageQueue.add("bulk-send", {
contacts: dto.contacts,
template: dto.template,
campaignId: dto.campaignId,
});
return {
status: "queued",
message: `${dto.contacts.length} messages queued for delivery`,
};
}
}
// Worker processes in background
@Processor("messages")
export class MessageProcessor {
@Process("bulk-send")
async handleBulkSend(job: Job) {
const { contacts, template, campaignId } = job.data;
// Process in batches of 50
const batches = chunk(contacts, 50);
for (const batch of batches) {
await Promise.all(
batch.map((contact) =>
this.whatsappService.send(this.formatMessage(contact, template)),
),
);
}
}
}
Result: POST /send-bulk dropped from 1,800ms to 95ms response time (the actual sending happens in background).
Problem 4 — No Caching on Repeated Reads
Our analytics endpoint was recalculating the same aggregations on every request — even when the underlying data hadn't changed in hours.
// ❌ Before — recalculating every time
async getAnalytics(campaignId: string) {
const [sent, delivered, failed, opened] = await Promise.all([
this.prisma.message.count({ where: { campaignId, status: 'sent' }}),
this.prisma.message.count({ where: { campaignId, status: 'delivered' }}),
this.prisma.message.count({ where: { campaignId, status: 'failed' }}),
this.prisma.message.count({ where: { campaignId, status: 'opened' }}),
]);
return { sent, delivered, failed, opened };
}
The fix — cache with Redis, invalidate when messages update:
// ✅ After — Redis caching
@Injectable()
export class AnalyticsService {
constructor(
private prisma: PrismaService,
private redis: RedisService,
) {}
async getAnalytics(campaignId: string) {
const cacheKey = `analytics:${campaignId}`;
// Check cache first
const cached = await this.redis.get(cacheKey);
if (cached) return JSON.parse(cached);
// Cache miss — compute and store
const [sent, delivered, failed, opened] = await Promise.all([
this.prisma.message.count({ where: { campaignId, status: "sent" } }),
this.prisma.message.count({ where: { campaignId, status: "delivered" } }),
this.prisma.message.count({ where: { campaignId, status: "failed" } }),
this.prisma.message.count({ where: { campaignId, status: "opened" } }),
]);
const result = { sent, delivered, failed, opened };
// Cache for 5 minutes
await this.redis.setex(cacheKey, 300, JSON.stringify(result));
return result;
}
}
Problem 5 — Fetching More Data Than Needed
Several endpoints were doing SELECT * and returning entire database rows when the client only needed 3-4 fields.
// ❌ Before — fetching everything
async getContacts() {
return this.prisma.contact.findMany(); // returns 30+ fields
}
// ✅ After — select only what's needed
async getContacts() {
return this.prisma.contact.findMany({
select: {
id: true,
name: true,
phone: true,
status: true,
},
});
}
Result: GET /contacts dropped from 950ms to 210ms.
| Endpoint | Before | After | Improvement |
|---|---|---|---|
GET /campaigns | 1,400ms | 320ms | 77% faster |
GET /messages | 1,100ms | 180ms | 84% faster |
POST /send-bulk | 1,800ms | 95ms | 95% faster |
GET /analytics | 1,300ms | 45ms | 97% faster |
GET /contacts | 950ms | 210ms | 78% faster |
| Average | 1,310ms | 170ms | 87% faster |
The overall average across all 15+ endpoints went from 1.2 seconds to under 600ms — which is where I set the target. Several endpoints went much further than that.
The 5 Things That Made the Difference
If you take nothing else from this post, take these:
- Measure before you optimize — log response times for 48 hours before touching anything. Guessing where the slowness is wastes time.
- N+1 queries are almost always the biggest culprit — look for any loop that contains a database call.
- Add indexes before you optimize queries — a well-indexed table makes every query faster instantly.
- Move anything slow to a queue — if a user doesn't need to wait for it, don't make them wait.
- Cache aggressively, invalidate carefully — Redis for anything that gets read more than it gets written.
What's Next
These same principles apply to any Node.js backend — Express, Fastify, NestJS, it doesn't matter. The problems are always some combination of N+1 queries, missing indexes, synchronous blocking, no caching, and over-fetching.
If your APIs are slow, start by measuring. The bottleneck is almost always obvious once you look at the numbers.
Have a slow API you're trying to fix? Drop a message — I'm always happy to talk through backend performance problems.