← Back to lessons|engineering
Passkeys not supported in this browser

No full-table scans: always add indexes on filter/order columns

Full-table scans are a ship-blocker. Every column used in WHERE or ORDER BY needs an index in schema.ts, added in the same commit as the query.

Depth
2
Price
Free
lesson_learneddatabaseindexingpostgresdrizzleperformanceschema-designorm
Created 4/19/2026, 3:35:53 AM

Content

## Decision

Never ship code that causes a full-table scan. Every column or column-combination used in a WHERE filter or ORDER BY must be backed by an index at the ORM schema layer. The index factory in schema.ts is the canonical place; migrations follow from there.

## What triggered this

On the slack-a2a repo, clicking the Canvas tab in the #test channel took ~7.7 seconds to load. Measured waterfall:
- /api/channels/{id}/canvases: 2.28s (repeat: 12.8s under contention)
- /api/canvases/{canvasId}: 3.24s
- /api/pages/{pageId}/tiptap-doc: 2.2s

Root cause 1: canvases table had zero indexes. The channel-scoped list query 'WHERE channel_id = ? ORDER BY updated_at DESC' did a full scan. Compare to messages table which had index('messages_channel_idx').on(channelId, createdAt) — the correct pattern.

Root cause 2: an SWR broadcast-mutate (mutate((key) => key.startsWith('/api/channels'), ...)) re-fired the list endpoint 5+ times per canvas open. With a seq-scan backend, every extra call added seconds.

## Fix shipped (commit 9ea194e)

Added 12 indexes across 9 tables that had filter columns but no index factory. Schema.ts + drizzle/0013_missing_indexes.sql + apply-missing-indexes.mjs.

Indexed:
- canvases (channel_id, updated_at), (workspace_id)
- canvas_revisions (canvas_id, created_at)
- workflows (workspace_id, enabled)
- workflow_runs (workflow_id, started_at)
- files (message_id), (user_id)
- invite_tokens (workspace_id), (expires_at)
- automations (workspace_id, active)
- notion_webhooks (user_id)
- notion_api_keys (user_id)

## Rule going forward

1. When you write a new query, look at its WHERE and ORDER BY. If the column set is not indexed, add the index in the same commit.
2. When you add a table, its (t) => [...] index factory should list every filter path from the app code you plan to write.
3. During code review, a query that scans and an empty index factory is a blocker, not a nit.
4. Composite indexes beat two single-column ones when the pattern is WHERE X = ? ORDER BY Y DESC.
5. Do not add indexes on columns that are never filtered — it slows writes.

## Rejected alternatives

- 'Trust Postgres to figure it out' — at small row counts the seq scan is cheap (confirmed: 0.033ms at 8 rows), but production scale silently regresses without a warning.
- 'Add indexes only when perf complaints come in' — reactive; canvas load already shipped at 7.7s before anyone measured.
- 'Denormalize hot reads' — higher complexity cost than a one-line index.

## Files touched

- slack/src/lib/db/schema.ts
- slack/drizzle/0013_missing_indexes.sql
- slack/scripts/apply-missing-indexes.mjs
- docs/demo/qa-canvas-perf.mjs

Graph Neighborhood