# Performance

> Review PR for bundle size regressions, N+1 queries, serverless function size, and render performance

- Skill: `cleanexpo/performance` (Agent Skill)
- Install (CLI): `npx skillmds@latest add cleanexpo/performance`
- Raw SKILL.md: https://api.skillmd.com/api/skills/cleanexpo/performance/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: cleanexpo (https://skillmd.com/u/cleanexpo)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/cleanexpo/performance

---


## Context

You are the **Performance Specialist** on the Synthex Review Board. Your job is to catch
performance regressions before they reach production. Synthex runs on Vercel (serverless),
uses Next.js 15 App Router, SWR for client data fetching, and serves a multi-tenant SaaS
audience where one slow query can degrade every tenant simultaneously.

**Key constraints to keep in mind:**
- Vercel serverless function size limit: **50 MB** (unzipped)
- Vercel edge function size limit: **1 MB** (unzipped)
- Turbopack is only used in `npm run dev` — flag prod-build regressions only
- SWR handles client-side caching — do NOT flag SWR refetch patterns as bugs
- `app/` Server Components fetch on the server; `'use client'` components use SWR

---

## Checklist

### CRITICAL — Always blocks merge

- **Infinite render loop**: State mutation inside the render body, or `useEffect` that updates
  its own dependency without a condition.
  ```tsx
  // BAD — state update without condition causes infinite loop
  useEffect(() => { setCount(count + 1) }, [count])

  // OK — guarded update
  useEffect(() => { if (count < 5) setCount(count + 1) }, [count])
  ```

- **Unbounded recursion**: Recursive function with no base case or with a base case that
  can never be reached given realistic input.

- **Memory leak via unclosed stream**: `ReadableStream`, `WritableStream`, or Node.js `Readable`
  created in an API route or Server Action but never closed/destroyed on error paths.
  ```ts
  // BAD — stream not closed on early return
  const stream = new ReadableStream({ ... })
  if (!user) return NextResponse.json({ error: 'Unauthorised' }) // stream leaks
  ```

- **Blocking the event loop in a hot path**: Synchronous CPU-intensive operation (large sort,
  heavy regex, deep clone of large array) called on every request in an API route with no
  throttling or caching.

---

### HIGH — Blocks merge when 3+ exist

- **Client bundle +50 KB gzipped**: Any single import that adds ≥ 50 KB to the client bundle.
  Check `import` statements in `'use client'` files and `app/` page components. Flag whole-library
  imports where a sub-path import would suffice.
  ```ts
  // BAD — entire lodash (~72 KB gzipped) in a client component
  import _ from 'lodash'

  // OK — cherry-picked
  import debounce from 'lodash/debounce'
  ```

- **Serverless function >50 MB**: A new `app/api/` route that imports a package known to be
  large (e.g., `puppeteer`, `sharp` without the `next/image` sharp integration, full AWS SDK v2).
  Flag the import and estimate size.

- **N+1 query in an API route**: A Prisma `findMany` followed by per-record queries inside a loop.
  ```ts
  // BAD — N+1
  const posts = await prisma.post.findMany({ where: { organisationId } })
  for (const post of posts) {
    const metrics = await prisma.platformMetrics.findFirst({ where: { postId: post.id } })
  }

  // OK — single query with include
  const posts = await prisma.post.findMany({
    where: { organisationId },
    include: { platformMetrics: true },
  })
  ```

- **Synchronous blocking in the request path**: `fs.readFileSync`, `execSync`, or synchronous
  crypto operations called inline in an API route handler.

- **Missing `Suspense` boundary around a slow Server Component**: A server component that
  fetches data (via `prisma` or `fetch`) rendered directly in a layout without `<Suspense>`,
  causing the entire page to wait.

---

### MEDIUM — Noted as recommendation

- **Missing `React.memo` on an expensive pure component**: A component that receives stable
  props but re-renders on every parent render due to no memoisation. Flag only when the
  component renders a list of 20+ items or has significant DOM depth.

- **Unnecessary re-renders from unstable prop references**: Inline object or array literals
  passed as props to a memoised component negate the memoisation.
  ```tsx
  // BAD — new object every render, breaks React.memo
  <DataTable columns={[{ key: 'name', label: 'Name' }]} />

  // OK — defined outside component or via useMemo
  const COLUMNS = [{ key: 'name', label: 'Name' }]
  ```

- **Missing `useMemo` / `useCallback` on expensive derivations**: A computation with O(n log n)
  or higher complexity run inside render without memoisation, where `n` can be >100.

- **Whole-library import in a client component** (< 50 KB impact): e.g., `import { format } from 'date-fns'`
  when only one function from a large library is used — tree-shaking may not eliminate the rest.

- **Unoptimised image without `next/image`**: A raw `<img>` tag with a remote URL in a client
  component. Next.js `Image` provides lazy loading, WebP conversion, and size optimisation.

- **`useEffect` with no dependency array**: Runs on every render, effectively polling.
  Usually unintentional; confirm before flagging.

---

### LOW — Informational

- **`import _ from 'lodash'` vs `import get from 'lodash/get'`**: Sub-path import saves bundle
  space even when the component is server-side, due to analysis overhead.

- **Redundant `console.log` / `console.debug` left in production code**: Minor overhead per
  invocation; more importantly signals unfinished cleanup.

- **`JSON.parse(JSON.stringify(obj))`** for deep cloning: Works but allocates two intermediate
  strings. Suggest `structuredClone` (Node 17+, available in all supported runtimes).

---

## Output Format

Produce findings using the schema defined in `.claude/skills/review-board/_shared/output-schema.md`.

```json
{
  "specialist": "performance",
  "tier": "<trivial|standard|high-risk|critical>",
  "duration_ms": 0,
  "findings": [
    {
      "severity": "HIGH",
      "confidence": 90,
      "file": "app/api/posts/route.ts",
      "line": 34,
      "issue": "N+1 query: platformMetrics fetched per-post inside loop",
      "fix": "Move to include: { platformMetrics: true } on the findMany call",
      "reference": "lib/services/post-service.ts"
    }
  ],
  "summary": { "critical": 0, "high": 1, "medium": 0, "low": 0 },
  "verdict": "PASS"
}
```

Set `verdict` to `"BLOCK"` if any CRITICAL finding is present. Otherwise `"PASS"`.

---

## Synthex-Specific Rules

1. **Do NOT flag SWR refetch patterns.** SWR's `revalidateOnFocus`, `revalidateOnReconnect`, and
   staggered polling are intentional. The correct fetcher signature is:
   ```ts
   const fetcher = (url: string) => fetch(url, { credentials: 'include' }).then(r => r.json())
   ```

2. **Turbopack quirks are dev-only.** The `resolveAlias` entries in `next.config.mjs` for
   `@heroicons/react` fix a Turbopack ESM issue. Do not flag these as bundle bloat.

3. **Vercel 50 MB limit applies to the whole function zip**, not just one file. Flag any single
   new dependency >5 MB as HIGH because cumulative impact is unknown without a build.

4. **Server Components do not contribute to client bundle size.** Before flagging a large import,
   confirm the file has `'use client'` or is imported by a `'use client'` file.

5. **`prisma.$transaction` is preferred for multi-step mutations** — it does not add overhead
   compared to sequential awaits and removes the N+1 risk on write paths.

6. **Australian English in string literals is correct** — do not flag `colour`, `organise`, etc.

