All posts

June 17, 2026

From 42 Queries to 4: Fixing N+1 Database Problems in Express.js and Drizzle ORM

A real-world performance optimization case study showing how eliminating N+1 database queries reduced API response times by up to 71%, cutting venue list requests from 2.7 seconds to 0.8 seconds and reducing database queries from 42 to 4.

From 42 Queries to 4: Fixing N+1 Database Problems in Express.js and Drizzle ORM

Performance tuning is often associated with adding Redis, upgrading servers, or introducing more infrastructure. In reality, many performance issues come from inefficient database access patterns.

While working on an internal concert management platform built with Express.js, TypeScript, Drizzle ORM, Neon Postgres, and Redis, I noticed several list endpoints were significantly slower than expected.

The Problem

Some API endpoints were taking between 1.4 and 2.7 seconds to respond.

At first glance, Redis caching appeared to be working because cache-related logs looked healthy. However, after digging deeper, I realized those slow requests were occurring during cache misses, where the API still had to build the response from the database.

Root Cause: N+1 Queries

The biggest bottleneck was the classic N+1 query problem.

For example, the venue listing endpoint would:

  1. Fetch all venues
  2. Loop through each venue
  3. Execute additional count queries per venue

This pattern dramatically increased database round trips.

With Neon Serverless Postgres, every additional query adds latency, making N+1 issues especially noticeable.

Example Pattern

javascriptread-only
1const venues = await db.select().from(venuesTable);
2
3for (const venue of venues) {
4  const eventCount = await db
5    .select({ count: count() })
6    .from(eventsTable)
7    .where(eq(eventsTable.venueId, venue.id));
8}

The code works, but it scales poorly.

The Fix

1. Batch Venue Enrichment

Instead of running count queries per venue, I collected all venue IDs and used a single grouped query.

javascriptread-only
1const counts = await db
2  .select({
3    venueId: eventsTable.venueId,
4    count: count(),
5  })
6  .from(eventsTable)
7  .where(inArray(eventsTable.venueId, venueIds))
8  .groupBy(eventsTable.venueId);

2. Batch Seating Layout Queries

Previously:

  • Fetch layouts
  • Query seats for each layout individually

Now:

  • Fetch all layouts
  • Fetch all seats using a single inArray() query

3. Batch Artist Conflict Checks

Artist conflict detection originally executed one query per artist.

This was replaced with a single batched query that retrieved conflicts for all artists at once.

4. Parallel Independent Reads

Several publishing checklist checks were independent of one another.

Using Promise.all() allowed them to run concurrently rather than sequentially.

javascriptread-only
1const [tickets, artists, venue] = await Promise.all([
2  getTickets(),
3  getArtists(),
4  getVenue(),
5]);

Results

Response Time Improvements

EndpointBeforeAfterVenue List~2.7s~0.8sConcert List~1.4s~0.3sArtist List~1.4s~0.3s

Query Count Improvements

OperationBeforeAfterVenue List42 Queries4 QueriesSeating Layout11 Queries2 QueriesArtist Conflict Check5 Queries1 Query

Key Takeaway

Before reaching for bigger servers, additional caching layers, or complex infrastructure, inspect your database access patterns.

The largest performance gains often come from:

  • Eliminating N+1 queries
  • Batching reads
  • Using aggregate queries
  • Running independent operations in parallel
  • Reducing database round trips

In this case, a few query optimizations reduced response times by up to 71% while simplifying the overall data access layer.

28