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:
// 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 + pagesgetEntityById()— entity detailgetTenantContext()— nearly every server actiongetUserPermissions()— permission checksgetEntityCounts()— dashboard + sidebargetResolvedNavConfig()— layoutgetEnabledSystemToolSlugs()— 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:
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:
// 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():
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/deletespans 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:
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
- Create the cached function in the appropriate
cached-queries.tsusingunstable_cache - Use
createAdminClient()(no cookies dependency) - Pass
tenantIdas parameter (becomes cache key) - Add tags for invalidation
- Create an
invalidateXCache(tenantId)helper - Call the invalidation helper from ALL mutation paths
- 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.
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