Documentation source
Performance Optimization
Data fetching, caching, code splitting, and monitoring patterns
# Performance Optimization
How the platform achieves fast page loads, efficient data fetching, and observable performance.
## Data Fetching Architecture
Three layers work together to minimize database roundtrips:
### Layer 1: Cross-Request Cache (Next.js Data Cache)
Stable, tenant-scoped data is cached across requests via `unstable_cache`. The cache persists until explicitly invalidated or the TTL expires (1 week safety net).
**What's cached:**
- Entity types (`getCachedEntityTypes`) — `entity-types-{tenantId}` tag
- Entity type by slug (`getCachedEntityTypeBySlug`) — same tag
- Tenant nav config (`getCachedTenantNavOverride`) — `nav-config-{tenantId}` tag
**Invalidation:** Every mutation path calls `invalidateEntityTypesCache(tenantId)` or `invalidateNavConfigCache(tenantId)`. These are centralized helpers — all code paths (server actions, admin tools, API routes) use them.
**Pattern:**
```typescript
// features/entities/server/cached-queries.ts
export async function getCachedEntityTypes(tenantId: string) {
const cached = unstable_cache(
async () => {
const supabase = createAdminClient();
return listAccessibleEntityTypes(supabase, tenantId);
},
[`entity-types-${tenantId}`],
{ tags: [`entity-types`, `entity-types-${tenantId}`], revalidate: 604800 }
);
return cached();
}
```
### Layer 2: Per-Request Dedup (React.cache)
Functions called from multiple server components in the same render are wrapped in `React.cache()` to execute only once per request.
**Cached functions:**
- `getEntityTypes()` — layout + pages
- `getEntityById()` — entity detail
- `getTenantContext()` — nearly every server action
- `getUserPermissions()` — permission checks
- `getEntityCounts()` — dashboard + sidebar
- `getResolvedNavConfig()` — layout
- `getEnabledSystemToolSlugs()` — tool registry
### Layer 3: Client-Side Cache (React Query)
Client components use React Query for caching, deduplication, and stale-while-revalidate.
**Stale times by data type:**
- Chat messages, workflows: 15-30 seconds
- Entities, comments, feed: 60 seconds
- Entity types, counts: 5 minutes
**initialData bridging:** Hooks accept `initialData` from SSR props to prevent double-fetch:
```typescript
export function useEntityTypes(options?: { initialData?: EntityTypeRecord[] }) {
return useQuery({
queryKey: entityTypesQueryKey(),
queryFn: fetchEntityTypes,
staleTime: 5 * 60 * 1000,
initialData: options?.initialData,
});
}
```
## Code Splitting
Zero-cost lazy loading for heavy libraries.
### Dynamic Shell Components
CommandPalette and ChatDockShell are mounted on every page but loaded via `next/dynamic` with `ssr: false`:
```typescript
// components/app-shell/lazy-shell.tsx
export const LazyCommandPalette = dynamic(
() => import("@/components/app-shell/command-palette").then(m => ({ default: m.CommandPalette })),
{ ssr: false }
);
```
### Lazy Block Registry
Heavy block types (chart, radar, bubble-chart, entity-graph) are lazy-loaded via `createLazyBlock()`:
```typescript
const LazyChartBlock = createLazyBlock(() =>
import("./chart-block").then(m => ({ default: m.ChartBlock }))
);
registerBlock("chart", { component: LazyChartBlock, defaultSize: "half" });
```
Core blocks (stat-cards, table, field-card, etc.) are always bundled.
### Graph Visualization
`@xyflow/react` (~120KB) is lazy-loaded on the `/graph` page via `next/dynamic`.
## Performance Monitoring
### Sentry Supabase Integration
`supabaseIntegration()` is enabled in both client and server Sentry configs:
- Automatic `db.select/insert/update/delete` spans for every Supabase query
- RLS error capture (previously silent failures)
- Feeds **Sentry Query Insights** dashboard
### Stuck Skeleton Detection
`NavigationTimingProvider` mounted in the app shell measures page navigation timing. Pages taking >3 seconds trigger a Sentry warning with pathname and duration.
### Server Action Timing
`measure()` from `lib/performance/measure.ts` wraps slow server actions:
```typescript
const result = await measure("searchEntities", () => fetchEntities(params));
```
### Supabase Dashboard
`pg_stat_statements` is enabled — use Dashboard > Query Performance to find slow server-side queries and get Index Advisor recommendations.
## Adding a New Cached Query
1. Create the cached function in the appropriate `cached-queries.ts` using `unstable_cache`
2. Use `createAdminClient()` (no cookies dependency)
3. Pass `tenantId` as parameter (becomes cache key)
4. Add tags for invalidation
5. Create an `invalidateXCache(tenantId)` helper
6. Call the invalidation helper from ALL mutation paths
7. Wrap the caller in `React.cache()` for per-request dedup
## Migration to `use cache`
When `cacheComponents: true` is stable (currently breaks prerendering on some pages), migrate `unstable_cache` to `'use cache'` + `cacheLife()` + `cacheTag()`. The pattern is identical but uses compiler directives instead of function wrappers. See the `next-cache-components` skill for details.
## Database Performance
For Postgres-level optimization (RLS policies, indexes, functions, pgTAP testing), see the [Database Optimization runbook](/docs/runbooks/database-optimization).
Key database optimizations applied:
- **RLS auth.uid() wrapping** — All policies use `(SELECT auth.uid())` for per-query caching
- **authorize() tenant scoping** — Permission checks scoped to active tenant only
- **Index deduplication** — Reduced entities indexes from 27→17 (37% fewer writes per mutation)
- **RLS coverage** — All 58 tables now have RLS enabled
- **pgTAP testing** — 54 tests covering RLS, indexes, and function correctness