NodeJs
1 Apr 2026
7 MIN READ

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:

EndpointAvg Response Time
GET /campaigns1,400ms
GET /messages1,100ms
POST /send-bulk1,800ms
GET /analytics1,300ms
GET /contacts950ms

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.

EndpointBeforeAfterImprovement
GET /campaigns1,400ms320ms77% faster
GET /messages1,100ms180ms84% faster
POST /send-bulk1,800ms95ms95% faster
GET /analytics1,300ms45ms97% faster
GET /contacts950ms210ms78% faster
Average1,310ms170ms87% 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:

  1. Measure before you optimize — log response times for 48 hours before touching anything. Guessing where the slowness is wastes time.
  2. N+1 queries are almost always the biggest culprit — look for any loop that contains a database call.
  3. Add indexes before you optimize queries — a well-indexed table makes every query faster instantly.
  4. Move anything slow to a queue — if a user doesn't need to wait for it, don't make them wait.
  5. 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.

Mentioned Technologies

#NodeJs#NestJs#Backend#API#Optimization#Performance#Redis#BullMQ#Prisma#PostgreSQL

Frequently Asked Questions

Common causes include unindexed or N+1 database queries, blocking synchronous code, missing caching, and oversized payloads. Profiling each layer is the first step to finding the real bottleneck.

Yes. Caching frequent read queries in Redis removes repeated database round-trips and can cut response times dramatically, especially for data that changes infrequently.

Use APM tools or timing middleware and separate database, external API, and serialization time so you optimize the actual slow part instead of guessing.

Built this in production

SNAPAURA — Digital Social Networking Platform

Social Media Platform — read the case study

Related Reading

Need this built right?

I'm a freelance NestJS & Node.js backend developer. Let's ship your MVP or SaaS.

Hire Me

Ready for more?

Explore other insights in the gallery.

Browse All Posts