How We Scaled Our Next.js App to 1 Million Monthly Users
An in-depth post-mortem and architectural review of how we scaled a Next.js application to handle 1 million monthly active users, detailing ISR, edge caching, database connection pooling, and Vercel optimization.

Introduction
Scaling an application from a few thousand users to over a million monthly active users (MAU) is a rite of passage for engineering teams. What starts as a simple, elegant Next.js project on Vercel can quickly degrade into a nightmare of database connection exhaustion, serverless function timeouts, ballooning hosting costs, and sluggish Core Web Vitals.
In this breakdown, we demystify the exact architecture, database strategies, and performance optimizations we implemented to scale our Next.js application to over 1.2 million monthly active users while keeping our cloud hosting bill under $250 a month and maintaining a sub-150ms P95 page load speed.
The Initial Bottlenecks
At around 100,000 monthly users, our initial MVP architecture started cracking under load. We were running a standard Next.js setup on Vercel Serverless with a hosted PostgreSQL instance on Supabase. We observed three critical bottlenecks:
- Database Connection Exhaustion: Because serverless functions scale horizontally by spinning up new isolated environments, our PostgreSQL connection pool was repeatedly exhausted during traffic spikes, throwing
500 Internal Server Errorresponses. - Serverless Cold Starts & Timeouts: Pages that relied heavily on Server-Side Rendering (
getServerSidePropsor dynamic App Router fetches) suffered from 2–3 second cold starts and occasional 10-second timeouts during downstream API delays. - Heavy Client Bundle Size: Poorly optimized dynamic imports and heavy libraries resulted in poor Core Web Vitals, specifically Cumulative Layout Shift (CLS) and Interaction to Next Paint (INP) on lower-end mobile devices.
Target System Architecture
To solve these issues, we transitioned from a purely dynamic database-driven model to an Edge-cached, Replica-backed, and Event-driven Architecture.
Here is how data flows through the optimized system:
+--------------------------------+
| User Browser |
+---------------+----------------+
|
HTTPS Requests | (Anycast routing)
v
+--------------------------------+
| Vercel Edge Network |
| (Cloudflare CDN Cache) |
+-------+---------------+--------+
| |
Cache Miss / | | Static Asset /
Dynamic Path | | ISR Hit (Sub-50ms)
v v
+---------------+ +--------------+
| Serverless | | Static Edge |
| Lambda / Edge | | Cache Store |
+-------+-------+ +--------------+
|
+------------+------------+
| |
v (Check Redis Cache) v (Writes / Transactions)
+---------------+ +---------------+
| Redis Cache | | PgBouncer / |
| (Upstash) | | Prisma Accel. |
+---------------+ +-------+-------+
|
v
+-------+-------+
| PostgreSQL |
| (Primary) |
+-------+-------+
|
v (Replication)
+-------+-------+
| PostgreSQL |
| (Read-Replica)|
+---------------+
By placing an Edge Caching Layer and a global Redis store in front of our primary database, we redirected 92% of read operations away from PostgreSQL.
Caching Strategy: The Holy Trinity
We implemented a three-tier caching system: Incremental Static Regeneration (ISR), Stale-While-Revalidate (SWR) on the client, and a server-side Redis caching wrapper for expensive dynamic database queries.
1. Incremental Static Regeneration (ISR)
For pages with semi-dynamic content (like blog posts, public profiles, and product pages), we used Next.js ISR. Instead of fetching the database on every single visit, Next.js serves a pre-rendered static page from the edge cache and updates it in the background asynchronously when a user requests a stale page.
// App Router config for a dynamic product page
export const revalidate = 3600; // Revalidate at most once every hour
export async function generateStaticParams() {
const products = await getPopularProducts();
return products.map((product) => ({
id: product.id,
}));
}
2. High-Performance Server-Side Redis Cache Wrapper
For pages that must contain near-real-time data but cannot afford a direct database hit on every page load, we built a highly optimized Redis caching client.
To save network bandwidth and Redis memory, we introduced Zlib-based binary compression for cached payloads.
import Redis from 'ioredis';
import zlib from 'zlib';
import { promisify } from 'util';
const gzip = promisify(zlib.gzip);
const gunzip = promisify(zlib.gunzip);
const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');
export async function getCachedData<T>(
key: string,
fetcher: () => Promise<T>,
ttlSeconds: number = 300
): Promise<T> {
// 1. Try fetching from Redis
const cachedBuffer = await redis.getBuffer(key);
if (cachedBuffer) {
try {
const decompressed = await gunzip(cachedBuffer);
return JSON.parse(decompressed.toString('utf-8')) as T;
} catch (err) {
console.error(`Decompression failed for key: ${key}`, err);
}
}
// 2. Cache Miss: Query original database
const freshData = await fetcher();
// 3. Compress and store in background
try {
const compressed = await gzip(Buffer.from(JSON.stringify(freshData)));
await redis.setex(key, ttlSeconds, compressed);
} catch (err) {
console.error(`Caching failed for key: ${key}`, err);
}
return freshData;
}
Database Connection Pooling & Read Replicas
Because Next.js API Routes run in serverless environments, they do not share global variables easily. When multiple Serverless Lambdas launch concurrently during high-traffic windows, each opens a new connection to PostgreSQL.
We resolved this bottleneck by implementing Prisma with Prisma Accelerate and configuring PgBouncer in transaction mode on our database.
Our Prisma client initialization was structured to avoid initiating connections during serverless cold starts:
import { PrismaClient } from '@prisma/client';
import { withAccelerate } from '@prisma/extension-accelerate';
const prismaClientSingleton = () => {
return new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
}).$extends(withAccelerate());
};
type PrismaClientSingleton = ReturnType<typeof prismaClientSingleton>;
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClientSingleton | undefined;
};
export const prisma = globalForPrisma.prisma ?? prismaClientSingleton();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
Additionally, we separated our reads and writes:
- Write Path: Direct connection to the Primary database via PgBouncer.
- Read Path: Distributed connections to Read-Replicas using global cache keys.
Performance Benchmarks & Key Metrics
Our efforts paid off drastically. Below is a comparative table showing our core system metrics before and after the architectural overhaul:
| Metric | Before Optimization | After Optimization | Improvement |
|---|---|---|---|
| P95 Latency (Edge) | 850ms | 110ms | 87% reduction |
| Max Concurrent DB Connections | 480 (Exhausted) | 34 (Pooled) | 92% reduction |
| Lighthouse Performance Score | 68/100 | 96/100 | +28 points |
| Max Scale Throughput | 120 req/sec | 4,500 req/sec | 3,650% increase |
| Monthly Cloud Cost | $640.00 | $185.00 | 71% savings |
Conclusion & Lessons Learned
Scaling a Next.js application to 1 million monthly users is not about deploying to a larger server. It is about minimizing the dependency on your data store using aggressive caching, ensuring efficient database connection management, and designing your application state around asynchronous, static-first workflows.
By decoupling the UI generation from the database runtime, we built a system that scales horizontally without scaling our budget.