Sprinter Docs

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 + 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:

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/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:

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.

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

On this page