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