Performance Tuning Before Launch
- performance
- postgresql
- redis
- nextjs
- nestjs
- caching
- database
- webdev
- saas
- typescript
In A Production Security Checklist, I walked through what to lock down before opening the doors to real users. Security is a pass/fail gate: you either did the work or you're exposed. Performance is different. Nothing is "broken" if your dashboard takes four seconds to load. It just quietly loses you users, and nobody files a ticket for that.
This is part of the Full Stack SaaS Masterclass, a build-it-for-real series that started with Choosing the Right Tech Stack for Your SaaS and has followed one app from an empty folder toward production. Performance tuning is the second-to-last stop before the readiness checklist that closes out this module.
I want to be upfront about the angle here. Most SaaS products at launch have a handful of genuinely slow paths and a long tail of things that don't matter yet. The job before launch is finding that handful, fixing it, and resisting the urge to optimize the tail on a guess. Squeezing out every millisecond can wait.
Measure before you touch anything
The single biggest performance mistake I see before a launch is optimizing from intuition instead of data. An engineer decides the ORM is slow, or the frontend bundle is too big, or Redis needs to cache everything, and spends a week on it. Sometimes they're right. Often the actual bottleneck was a missing index on a table nobody thought to check.
Before touching code, get three numbers for your key user flows: server response time, database query time, and time to first meaningful paint on the frontend. You don't need a fancy APM for this at launch. Structured request logging with a duration field, combined with PostgreSQL's pg_stat_statements, tells you almost everything.
-- Enable once, then query anytime to find your slowest queries
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
SELECT
calls,
round(mean_exec_time::numeric, 2) AS avg_ms,
round(total_exec_time::numeric, 2) AS total_ms,
query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;
This one view does more for pre-launch performance than any amount of speculative tuning. It ranks queries by total time spent, which surfaces both the genuinely slow query and the moderately fast one that runs ten thousand times per hour. Fix in that order.
The database is almost always the first bottleneck
For a typical SaaS backed by Postgres, the database is where response time actually goes. Two patterns account for most of it: N+1 queries and missing indexes on foreign keys or filter columns.
N+1 queries sneak in through ORMs more than raw SQL, because the abstraction hides the loop. Here's the shape that shows up constantly in a multi-tenant NestJS app with TypeORM or Prisma:
// Bad: one query per organization to fetch its owner
async function getOrganizationsWithOwners(orgIds: string[]) {
const orgs = await this.orgRepository.findByIds(orgIds);
return Promise.all(
orgs.map(async (org) => {
const owner = await this.userRepository.findOne({ where: { id: org.ownerId } });
return { ...org, owner };
}),
);
}
// Better: one query, using a join or relation load
async function getOrganizationsWithOwners(orgIds: string[]) {
return this.orgRepository.find({
where: { id: In(orgIds) },
relations: { owner: true },
});
}
The fix is rarely clever. It's usually "load the relation you already know you need instead of fetching it in a loop." What makes this worth checking before launch specifically is that N+1 patterns are invisible at ten rows and painful at ten thousand. Your staging data almost never has enough rows to expose it, which is why this bites teams right after their first real customer imports their existing data.
Indexes are the second lever, and the rule of thumb is straightforward: index every foreign key you filter or join on, and every column you filter by in a WHERE clause on a table that will grow past a few thousand rows. For a multi-tenant schema, that almost always includes tenant_id or organization_id, since nearly every query in the app filters by it.
-- Composite index for the most common query shape in a multi-tenant table
CREATE INDEX idx_invoices_org_created_at
ON invoices (organization_id, created_at DESC);
Run EXPLAIN ANALYZE on your top five queries from the pg_stat_statements list before launch. A sequential scan on a table that will hold real production volume is the clearest signal you'll get.
Connection pooling deserves a mention here too. It shows up as a launch-week failure mode more than a slow-query one. Each Postgres connection costs real memory on the server, and a NestJS app under load can exhaust the default pool fast if every request opens its own connection without limits. Set an explicit, sane pool size (TypeORM and Prisma both expose this) rather than relying on defaults. Put PgBouncer in front of Postgres once you have more than one backend instance, so connections are shared instead of multiplied per process.
Add caching for a reason, not a habit
Redis earns its place in the tech stack precisely for moments like this: an expensive, frequently-read query that doesn't need to be perfectly fresh. That's the profile to look for. Caching things that change on every write, or things nobody reads often, adds invalidation complexity for no real gain.
The cache-aside pattern covers the vast majority of pre-launch caching needs, and it's simple enough to reason about under pressure:
async function getDashboardSummary(orgId: string): Promise<DashboardSummary> {
const cacheKey = `dashboard:summary:${orgId}`;
const cached = await this.redis.get(cacheKey);
if (cached) {
return JSON.parse(cached) as DashboardSummary;
}
const summary = await this.computeDashboardSummary(orgId);
await this.redis.set(cacheKey, JSON.stringify(summary), 'EX', 60);
return summary;
}
A short TTL, sixty seconds in this example, buys you most of the win without demanding a correctness-critical invalidation strategy. If the underlying data changes and the dashboard is a minute stale, that's a fine tradeoff for a summary view. It would not be a fine tradeoff for an account balance.
The pitfall worth naming directly: caching is not a substitute for fixing a slow query or a missing index. I've seen teams cache a query that runs an unindexed scan, which hides the problem for cache hits and leaves it fully exposed on every cache miss and every cold start after a deploy. Fix the query first. Cache it second, if it's still worth caching after the fix.
Frontend performance is mostly about what you ship, not how fast it runs
On the Next.js side, the biggest pre-launch win is almost always reducing what gets sent to the browser, not micro-optimizing render performance. A dashboard that ships 400KB of JavaScript for a page with three widgets will feel slow on a mid-range laptop and genuinely bad on mobile, no matter how well the React components are written.
A few checks that consistently matter before launch:
- Server Components by default. If you're on the App Router, keep interactive client components as small and as low in the tree as possible. A page doesn't need
'use client'at the top just because one button needs anonClick. - Dynamic imports for anything heavy and non-critical. Charting libraries, rich text editors, and PDF generators are common offenders. Load them on demand instead of in the initial bundle.
next/imagefor anything user-facing. It handles responsive sizing and lazy loading by default, which covers a large share of perceived load time on marketing and dashboard pages alike.- Static or ISR where the data allows it. Marketing pages, pricing pages, and public docs almost never need to be server-rendered on every request. Static generation with revalidation removes an entire request-response cycle from the critical path.
// Defer a heavy, non-critical component instead of bundling it upfront
import dynamic from 'next/dynamic';
const BillingChart = dynamic(() => import('./billing-chart'), {
loading: () => <ChartSkeleton />,
ssr: false,
});
None of this requires a performance specialist. It requires someone to actually open the network tab on the pages a real user hits most, which for a SaaS is usually the login flow, the main dashboard, and whatever page drives the core value proposition.
Load test before real users do it for you
Before launch is the only time you can safely find your breaking point without an actual customer discovering it. A basic load test against your staging environment, hitting the two or three endpoints that matter most, tells you where the system falls over and whether that ceiling is comfortably above your expected launch traffic.
# Quick load test against a staging endpoint with autocannon
npx autocannon -c 50 -d 30 -m POST \
-H "Content-Type: application/json" \
-b '{"email":"test@example.com","password":"test1234"}' \
https://staging.example.com/api/auth/login
Focus on the shape of the result, not a raw requests-per-second number. That number depends entirely on your infrastructure, and I won't fabricate one here. Does latency stay roughly flat as concurrency climbs, or does it fall off a cliff at some point? A cliff tells you exactly where your connection pool, your rate limiter, or your event loop is running out of headroom. It's far cheaper to find that in a load test than in your first traffic spike.
Key takeaways
- Measure before optimizing. `pg_stat_statements` and basic request timing find real bottlenecks; guessing usually finds the wrong ones.
- N+1 queries and missing indexes account for most database slowness in a typical SaaS, and both are invisible until real data volume exposes them.
- Set an explicit connection pool size and put PgBouncer in front of Postgres once you scale past one backend instance.
- Cache with Redis for expensive, frequently-read, tolerant-of-staleness data. Fix the underlying query first; cache it second.
- On the frontend, reducing what you ship (dynamic imports, Server Components, `next/image`, static generation) usually beats micro-optimizing render code.
- Run a basic load test against staging before launch so you find your ceiling on your own terms.
Frequently asked questions
How do I know if my Postgres queries are slow before launch?
Enable `pg_stat_statements` and query it for total execution time, not just average time per call. A moderately fast query that runs constantly can cost more overall than a rare slow one. Run `EXPLAIN ANALYZE` on the top results to confirm whether the plan is doing a sequential scan where an index would help.
What connection pool size should I use for Postgres in a NestJS app?
There's no universal number, since it depends on your database's `max_connections` setting and how many app instances you'll run. Start conservative, explicitly set the pool size rather than relying on the ORM default, and add PgBouncer in transaction mode once you run more than one backend instance so pooled connections are shared instead of multiplied.
Should I add Redis caching before launch even if I don't need it yet?
No. Add it when you have an identified expensive, frequently-read query that can tolerate a short staleness window. Caching before you have that signal adds invalidation complexity with no measured benefit, and it can hide a query problem you still need to fix.
How much frontend performance work is actually needed before launch?
Focus on what ships to the browser: keep client components small, dynamically import heavy non-critical widgets, use `next/image`, and statically generate pages that don't need per-request data. This tends to matter more before launch than micro-optimizing component render performance.
What's a reasonable way to load test before launch without a dedicated performance team?
A tool like autocannon or k6 pointed at your two or three highest-traffic endpoints in staging is enough to find where latency starts climbing as concurrency increases. You're looking for the shape of the curve, a gradual rise versus a cliff, not a specific number to hit.
Is it a problem if some pages are still slow at launch?
Not necessarily. The goal before launch is fixing the flows real users hit constantly, like login and the main dashboard. A rarely used settings page loading in a second and a half is a reasonable tradeoff against spending launch week chasing it instead of shipping.
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.