Coreframe Labs

Automated performance audit

Performance audits for Next.js data access

Next.js makes data fetching local to the component that needs it, which is excellent for maintainability and terrible for spotting query patterns. The database sees the aggregate; no single file looks unreasonable. These are the shapes that cause it.

Check a file now — free

Paste one file. The free tier runs a single security pass and returns up to three findings, each with the line of code that evidences it. Nothing is stored, nothing is used for training, and detected secrets are redacted before analysis.

Paste one file0 / 400 lines

Not stored. Not trained on. Secrets redacted before analysis.

What we find most often in Next.js data access

Each of these is a real pattern, with the code that causes it and the change that fixes it.

1. A new connection pool per serverless invocation

Symptom
Fine in development. In production the database hits max_connections under moderate traffic and everything stalls.

The pattern

// lib/db.ts
import { PrismaClient } from "@prisma/client";

export const db = new PrismaClient();   // new pool on every cold start

Why it bites
Each serverless instance constructs its own client, and each client opens its own pool. Fifty concurrent instances at a default pool size of ten is five hundred connections against a Postgres box configured for a hundred. The failure is a connection error, not a slow query, so it gets misdiagnosed as a database outage.

The fix

// Reuse across hot invocations, and put a real pooler in front.
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };

export const db =
  globalForPrisma.prisma ??
  new PrismaClient({ datasourceUrl: process.env.DATABASE_URL });

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;

// DATABASE_URL should point at PgBouncer / Prisma Accelerate in transaction
// mode, with ?connection_limit=1 per instance.

2. N+1 queries hidden by component composition

Symptom
The list page takes 4 seconds. Every individual query in the logs is under 2ms.

The pattern

async function PostRow({ post }: { post: Post }) {
  const author = await db.user.findUnique({ where: { id: post.authorId } });
  return <li>{post.title} — {author?.name}</li>;
}

export default async function Page() {
  const posts = await db.post.findMany({ take: 50 });
  return <ul>{posts.map((p) => <PostRow key={p.id} post={p} />)}</ul>;
}

Why it bites
Fifty rows means fifty-one round trips. Each is fast, so no slow-query log fires — the cost is entirely latency multiplied by count. Because the query lives inside the child component, code review of either file in isolation shows nothing wrong.

The fix

export default async function Page() {
  const posts = await db.post.findMany({
    take: 50,
    include: { author: { select: { name: true } } },   // one query, one join
  });
  return <ul>{posts.map((p) => (
    <li key={p.id}>{p.title} — {p.author.name}</li>
  ))}</ul>;
}

3. Sequential awaits creating a request waterfall

Symptom
Time-to-first-byte is the sum of every query, even though none of them depend on each other.

The pattern

const user = await getUser(id);
const orders = await getOrders(id);
const invoices = await getInvoices(id);
// 120ms + 180ms + 150ms = 450ms

Why it bites
`await` on consecutive lines serializes independent work. The database could have answered all three concurrently in the time the slowest one takes; instead the page pays the sum.

The fix

const [user, orders, invoices] = await Promise.all([
  getUser(id),
  getOrders(id),
  getInvoices(id),
]);
// max(120, 180, 150) = 180ms

4. Selecting every column to render three

Symptom
Queries are indexed and fast, but payloads are large and the RSC response is slow over the wire.

The pattern

const posts = await db.post.findMany({ take: 100 });
// pulls body, metadata JSON, embeddings — to render title and date

Why it bites
A wide `SELECT *` forces the database to read full rows off disk rather than serving from a covering index, and every unused column is serialized into the RSC payload and shipped to the browser. On tables with text or JSON columns this dominates the response.

The fix

const posts = await db.post.findMany({
  take: 100,
  select: { id: true, title: true, createdAt: true },
});

An automated pass reads one file. An audit reads the system.

The tool above is deliberately narrow — one file, one dimension, one pass. The issues that actually take systems down live between files: in the trust boundary nobody documented, the query pattern that only emerges at scale, the cache that was correct until the second tenant arrived. That is what the Coreframe Technical Audit is for — five business days, a written report, a live findings call, and a remediation proposal you can execute with or without us.

£1,500–£2,500 solo · £3,000–£5,000 funded startup · £3,000–£6,000 SME

See what the full audit covers →

Related audits

All stack audits →