Skip to content

Phase 3: DB Tuning — N+1 and Query Shape Cleanup

Goal

Reduce Prisma operation count, SQL statement count, and query duration before finalizing Prisma Postgres as the primary DB.

Prisma's query optimization docs identify common issues including over-fetching, missing indexes, lack of caching, and full table scans. They also describe the N+1 problem and recommend nested reads, in filters, and relationLoadStrategy: "join" to reduce repeated queries. Source: Prisma query optimization.

Primary tuning targets

ProblemSymptomFix
N+1 query loopsMany repeated similar queriesinclude, select, in, batch loaders, joins
Over-fetchingLarge response/query payloadsExplicit select fields
Repeated workspace context loadsSame membership/role/brand loaded repeatedlyShared workspace context loader + cache
Unbounded list queriesFull-table scans or slow page loadsCursor/keyset pagination
Chatty background jobsHigh operation count while idleBatching, bounded polling, queue limits
Excessive small writesMany one-row inserts/updatescreateMany, updateMany, bulk jobs

N+1 cleanup

Find patterns

Search for loops around Prisma calls:

bash
grep -RniE "for .*await prisma|forEach\(async|map\(async" src
grep -RniE "prisma\.[a-zA-Z0-9_]+\.findMany" src

Bad pattern:

ts
const posts = await prisma.post.findMany({
  where: { workspaceId },
});

for (const post of posts) {
  const author = await prisma.user.findUnique({
    where: { id: post.authorId },
  });
}

Better pattern with include/select:

ts
const posts = await prisma.post.findMany({
  where: { workspaceId },
  select: {
    id: true,
    title: true,
    status: true,
    author: {
      select: {
        id: true,
        name: true,
        email: true,
      },
    },
  },
});

Better pattern with in:

ts
const posts = await prisma.post.findMany({
  where: { workspaceId },
  select: { id: true, authorId: true },
});

const authors = await prisma.user.findMany({
  where: { id: { in: posts.map(p => p.authorId) } },
  select: { id: true, name: true, email: true },
});

Consider relationLoadStrategy: "join" where it reduces SQL statements and works for the query shape.

Over-fetching cleanup

Default list views should not load long body fields.

Bad

ts
await prisma.contentItem.findMany({
  where: { workspaceId },
});

Better

ts
await prisma.contentItem.findMany({
  where: { workspaceId },
  select: {
    id: true,
    title: true,
    status: true,
    platform: true,
    scheduledAt: true,
    updatedAt: true,
    campaignId: true,
    brandId: true,
  },
  take: 50,
  orderBy: { updatedAt: "desc" },
});

Load body/content fields only in editor/detail routes.

Workspace context loader

Create a single loader:

ts
export async function getWorkspaceContext(userId: string, workspaceId: string) {
  return prisma.workspaceMember.findFirst({
    where: {
      userId,
      workspaceId,
    },
    select: {
      id: true,
      role: true,
      workspace: {
        select: {
          id: true,
          name: true,
          activeBrandId: true,
        },
      },
      user: {
        select: {
          id: true,
          email: true,
          name: true,
        },
      },
    },
  });
}

Use this everywhere rather than reloading user, workspace, membership, active brand, and permissions separately.

Pagination

Avoid unbounded findMany.

Use cursor/keyset pagination:

ts
await prisma.contentItem.findMany({
  where: { workspaceId },
  take: 50,
  cursor: cursor ? { id: cursor } : undefined,
  skip: cursor ? 1 : 0,
  orderBy: [
    { updatedAt: "desc" },
    { id: "desc" },
  ],
});

For calendar ranges, constrain by date:

ts
await prisma.contentItem.findMany({
  where: {
    workspaceId,
    scheduledAt: {
      gte: startDate,
      lt: endDate,
    },
  },
  orderBy: { scheduledAt: "asc" },
});

Bulk writes

Prisma recommends bulk operations for large read/write workloads. Source: Prisma query optimization.

Use:

ts
await prisma.publishAttempt.createMany({
  data: attempts,
  skipDuplicates: true,
});

Instead of:

ts
for (const attempt of attempts) {
  await prisma.publishAttempt.create({ data: attempt });
}

Background job rules

Background jobs must be bounded:

Job typeRule
Publishing queue scanQuery only next due window; use take.
Social syncPaginate; store cursor/checkpoint.
AI generation cleanupBatch delete/archive.
Webhook processingAck quickly; process async; archive raw payload outside Prisma where possible.
Scheduled retriesIndex status/runAt; never scan all historical rows.

Query Insights attribution

Prisma Query Insights can trace SQL back to Prisma ORM model/action/query shape if @prisma/sqlcommenter-query-insights is configured. Source: Prisma query optimization.

Add:

bash
npm install @prisma/sqlcommenter-query-insights

Then configure the client per current Prisma adapter setup.

Acceptance criteria

ItemTarget
Top 20 expensive actions reviewed100%
Obvious N+1 loops removed100%
List routes use explicit select100%
Detail routes load long fields only when neededYes
Large list routes paginatedYes
Background jobs boundedYes
Workspace context loaded once per request/action where practicalYes
Query Insights shows no obvious repeated query stormsYes
Operation reports show before/after reductionYes

Sources

TendSocial Documentation