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:
- Fetch all venues
- Loop through each venue
- 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
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.
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.
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.