Scaling a SaaS Platform from 500 to 15,000 Concurrent Users
How we re-architected a Next.js + Node.js platform to handle 30x traffic growth by splitting reads/writes, adding caching, and optimizing the database layer.
Representative project based on real engagements. Client details anonymized.
The situation
A B2B SaaS platform built with Next.js and Node.js was hitting performance walls. The app worked fine with 500 concurrent users, but during peak hours (9-11 AM), response times spiked to 800ms+ and the database CPU hit 90%. The team was about to launch a marketing campaign that would 10x their user base.
The root cause: a single PostgreSQL instance handled both reads and writes. The most expensive queries were joins across 5-6 tables for the main dashboard view. There was no caching layer. Every page load hit the database directly.
What we changed
Database layer (week 1-2):
- Migrated from a single RDS PostgreSQL instance to AWS Aurora with 2 read replicas
- Added PgBouncer for connection pooling (the app was opening a new connection per request)
- Rewrote the 3 most expensive queries: replaced multi-table joins with materialized views that refresh every 5 minutes
- Added composite indexes for the most common query patterns
Caching layer (week 2-3):
- Added Redis (ElastiCache) for session storage and frequently accessed data
- Dashboard data is cached with a 60-second TTL — users see slightly stale data instead of slow queries
- API responses for reference data (dropdowns, user lists, config) are cached for 5 minutes
Application layer (week 3-4):
- Split the monolithic Node.js API into 3 containerized services: auth, core API, and reporting
- Each service runs on ECS Fargate with auto-scaling based on CPU/memory
- The reporting service (heaviest queries) scales independently from the auth service
Frontend (week 4):
- Added Next.js ISR (Incremental Static Regeneration) for pages that change infrequently
- Implemented optimistic UI updates for common actions (no waiting for server response)
- Added loading skeletons instead of spinners for perceived performance
The numbers
| Metric | Before | After |
|---|---|---|
| Concurrent user capacity | 500 | 15,000+ |
| Average API response time | 320ms | 45ms |
| P99 response time under load | 800ms+ | 120ms |
| Database CPU at peak | 90% | 35% |
| Infrastructure cost | $1,200/month | $720/month |
| Uptime | 99.5% | 99.9% |
What we learned
The biggest performance win was not adding servers — it was fixing the queries. The materialized views alone cut the dashboard load time by 70%. The connection pooling fix (PgBouncer) was a close second — the app was spending more time opening database connections than executing queries.
The cost reduction surprised the team. Aurora with read replicas sounds expensive, but it replaced the single over-provisioned RDS instance. The auto-scaling Fargate services scale down during off-hours, so they pay for capacity only when needed.
The lesson: scale the database first, add servers last. Most performance problems are query problems, not capacity problems.
Have a similar problem?
Book a short conversation with a senior engineer and we will tell you honestly whether we can help.