Caching Strategy & Data Fetching Patterns
Multi-layer caching architecture and data fetching best practices for the Fleetera platform.
The Fleetera platform manages thousands of assets, connectors, and telemetry data points. At this scale, a well-designed caching strategy is essential for fast page loads and responsive interactions. This guide explains how data flows through four caching layers and provides concrete patterns for adding new features.
Multi-Layer Caching Architecture
Data in the Fleetera frontend passes through up to four caching layers before reaching the user. Each layer serves a different purpose and operates at a different timescale.
Browser (React Query)
↕ client-side fetch via /api/* route handlers
Next.js Server (Data Cache)
↕ server-side fetch to backend services
Backend Services (Valkey)
↕ in-memory cache for computed results
ClickHouse (Materialized Views)
↕ pre-aggregated telemetry rollupsLayer 1 — Next.js Data Cache
The Next.js Data Cache stores responses from fetch() calls made in Server Components. When a Server Component fetches data from a backend service, the response is cached on the Next.js server and reused for subsequent requests until the cache is invalidated.
The platform uses a set of cache policy presets defined in lib/api-client.ts:
| Preset | Strategy | TTL | Use Case |
|---|---|---|---|
REFERENCE | force-cache | 5 min | Seed data that rarely changes (site categories, asset categories, variable libraries) |
ENTITY_LIST | force-cache | 60 s | List views (sites, assets, connectors) |
ENTITY_DETAIL | force-cache | 2 min | Detail views (single asset, single connector) |
USER_DATA | force-cache | 2 min | User and organization data (users, tenants, invitations) |
FRESH | no-store | -- | Volatile data that must always be fetched fresh (deployment status, shadow state, events) |
Each preset attaches cache tags that enable targeted invalidation. For example, fetching a list of sites uses:
export const getSites = cache(async (): Promise<Site[]> => {
const headers = await getAuthHeaders()
return assetServiceClient.get<Site[]>("/sites", {
headers,
...CachePolicy.ENTITY_LIST(["sites"]),
})
})The CachePolicy.ENTITY_LIST(["sites"]) call expands to { cache: "force-cache", next: { revalidate: 60, tags: ["sites"] } }. This tells Next.js to cache the response for up to 60 seconds and associate it with the "sites" tag for on-demand invalidation.
The TTL acts as a safety net. Even if a tag-based invalidation is missed (for example, due to a bug in a server action), the data will still refresh after the TTL expires. This prevents the "forever stale" problem.
The default behavior when no preset is specified is cache: "no-store", which means developers must explicitly opt into caching. This is a deliberate choice to prevent accidental stale data.
Layer 2 — React Query (TanStack Query)
Client Components use TanStack React Query for data that needs polling, user-interactive filtering, or real-time updates. The global configuration is set in the QueryProvider:
new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
staleTime: 5 * 60 * 1000, // 5 minutes
},
},
})Individual hooks override these defaults based on data volatility:
| Hook | Stale Time | Refetch Interval | Purpose |
|---|---|---|---|
useLatestReadings | 10 s | Configurable | Live telemetry values for an asset |
useReadingsRange | 30 s | Configurable | Historical readings in a time range |
useAggregateReadings | 30 s | Configurable | Aggregated readings (avg, min, max) |
usePipelineOverview | 60 s | 60 s | Fleet-level KPIs (auto-refresh) |
usePipelineMetrics | 30 s | -- | Ingestion throughput time-series |
usePipelineCoverage | 5 min | -- | Signal coverage matrix |
usePipelineStorage | 5 min | -- | Storage metrics (row counts, bytes) |
React Query hooks fetch data through Next.js API route handlers (for example, /api/readings/latest), which proxy requests to backend services. This keeps backend URLs and auth tokens on the server side.
Layer 3 — Valkey (Server-Side Cache)
Valkey (a Redis-compatible key-value store) is used by backend services for three purposes:
- Computed result caching -- The data-service caches expensive pipeline coverage computations with a 30-second TTL.
- Real-time data store -- The data-worker writes the latest telemetry reading for each asset variable into a Valkey hash, enabling sub-second lookups for live dashboards.
- Distributed coordination -- The asset-service uses Valkey for publish locks (preventing concurrent config publishes) and debounce timers (batching rapid config changes).
See the Valkey Key Namespaces section for the full list of key patterns.
Layer 4 — ClickHouse Materialized Views
ClickHouse pre-aggregates raw telemetry data into hourly and daily rollup tables using materialized views:
telemetry_hourly-- Aggregated min, max, sum, and count per hour per variabletelemetry_daily-- Aggregated min, max, sum, and count per day per variable
These rollups are computed automatically as data is inserted into the base telemetry table. The aggregate readings API selects from the appropriate rollup table based on the requested time range and bucket size, reducing query times from seconds to milliseconds for long time ranges.
Valkey Key Namespaces
Both the asset-service and data-service use Valkey with consistent key naming. The general pattern is {service}:{entity}:{id}:{purpose}.
| Key Pattern | Service | Type | TTL | Description |
|---|---|---|---|---|
latest:{tenantId}:{assetId} | data-service | Hash | None | Latest telemetry reading per variable. Each hash field is a variable key; the value is a JSON-encoded reading event. |
t:{tenantId}:{assetId} | data-service | Pub/Sub channel | -- | Real-time reading events published by the data-worker. The streaming gateway subscribes to these channels. |
pipeline:coverage:{tenantId}:{deploymentId} | data-service | String (JSON) | 30 s | Cached signal coverage matrix for a deployment. |
shadow:lock:{deploymentId} | asset-service | String | 60 s | Distributed publish lock. Prevents concurrent config publishes for the same deployment. Uses SET NX EX with Lua-script release. |
shadow:debounce:pending | asset-service | Sorted Set | None | Distributed debounce entries. Score is the Unix timestamp when the debounce should fire. |
shadow:debounce:member:{deploymentId} | asset-service | String | 40 s | Reverse lookup for debounce cancellation. Maps deployment ID to the sorted set member string. |
Both services degrade gracefully when Valkey is unavailable. The data-service skips caching (computes on every request), and the asset-service falls back to in-memory locks and setTimeout-based debounce timers.
Server Components vs. React Query
Use the following decision tree to choose between Server Components (Next.js Data Cache) and React Query (client-side) for a given data need.
Use Server Components when:
- The data is needed for the initial page render (for example, an asset detail page)
- The data changes primarily in response to user mutations (create, update, delete) rather than external events
- SEO is relevant (for example, a public-facing page)
- The data can be invalidated via cache tags after a mutation
Use React Query when:
- The data changes independently of user actions (for example, live telemetry readings, deployment heartbeat status)
- The UI needs polling or real-time updates (
refetchInterval, SSE streams) - The user interacts with filters, time range selectors, or pagination that should not trigger full page reloads
- The data is too volatile for server-side caching (for example, coverage matrix that changes every few seconds)
Concrete examples:
| Data | Approach | Reason |
|---|---|---|
| Site list | Server Component + ENTITY_LIST | Changes on mutation, needs fast initial load |
| Asset detail | Server Component + ENTITY_DETAIL | Changes on mutation, used in page layout |
| Latest telemetry readings | React Query + SSE stream | Changes every few seconds from edge devices |
| Pipeline throughput chart | React Query + 30s stale time | Time-series data with user-controlled filters |
| Deployment status | Server Component + no-store | Changes from external heartbeats, always fetch fresh |
| Asset categories | Server Component + REFERENCE | Seed data that rarely changes |
Server-Side Data Fetching Pattern
The platform uses a three-layer pattern for server-side data access:
lib/dal.ts → Session verification (cached per request)
lib/data/*.ts → Data access functions (fetch from backend services)
lib/actions/*.ts → Server Actions (mutations + cache invalidation)Data Access Layer (lib/data/)
Each entity has a dedicated file in lib/data/ that exports read functions. These functions verify the session, build auth headers, and call the appropriate backend service with caching:
import "server-only"
import { cache } from "react"
import { assetServiceClient, CachePolicy } from "@/lib/api-client"
import { verifySession } from "@/lib/dal"
export const getSites = cache(async (): Promise<Site[]> => {
const headers = await getAuthHeaders()
return assetServiceClient.get<Site[]>("/sites", {
headers,
...CachePolicy.ENTITY_LIST(["sites"]),
})
})Key conventions:
import "server-only"at the top of every data file. This prevents accidental import in Client Components.cache()wrapper from React deduplicates identical calls within a single request. If two Server Components on the same page both callgetSites(), the fetch runs only once.- Cache tags follow a naming convention: entity-level tags like
"sites"or"assets", and scoped tags like"site-{id}-assets"or"connector-{connectorId}".
Cache Tag Naming Conventions
| Pattern | Example | Scope |
|---|---|---|
{entity} (plural) | "sites", "assets", "connectors" | All entities of this type |
{entity}-{id} | "asset-abc123", "connector-def456" | Single entity |
{parent}-{parentId}-{children} | "site-abc-assets", "site-abc-connectors" | Entities scoped to a parent |
{entity}-{id}-{relation} | "asset-abc-children", "asset-abc-attributes" | Related data for an entity |
Server Actions (lib/actions/)
Server Actions handle mutations and are responsible for cache invalidation. After a successful mutation, they call updateTag() to invalidate the relevant cache entries and revalidatePath() to clear the full route cache for affected pages:
"use server"
import { revalidatePath, updateTag } from "next/cache"
export async function createAssetAction(siteId: string, data: CreateAssetInput) {
try {
const asset = await createAsset(data)
// Invalidate cache tags (Data Cache)
updateTag("assets")
updateTag("sites")
updateTag(`site-${siteId}-assets`)
if (data.primaryParentId) {
updateTag(`asset-${data.primaryParentId}-children`)
}
// Invalidate paths (Full Route Cache)
revalidatePath(`/sites/${siteId}`, "layout")
revalidatePath(`/sites/${siteId}/assets`)
return { success: true, data: asset }
} catch (error) {
return { success: false, error: handleApiError(error) }
}
}Always invalidate both the entity-level tag (for example, "assets") and any scoped tags (for example, "site-{siteId}-assets"). Missing a tag means some views will show stale data until the TTL expires.
Loading.tsx Patterns
Every page that fetches data in a Server Component should have a corresponding loading.tsx file. This file renders a skeleton placeholder while the server fetches data, preventing the browser from appearing frozen during navigation.
How It Works
When a user navigates to a route, Next.js renders loading.tsx immediately while the page's Server Component awaits its data. The key requirement is that the skeleton must match the dimensions of the final content to prevent layout shift.
Example
For a site detail page that renders a header, stats cards, and an information card:
import { Skeleton } from "@workspace/ui/components/skeleton"
export default function SiteDetailLoading() {
return (
<div className="flex flex-1 flex-col gap-4 p-6">
{/* Header: title + badge + edit button */}
<div className="flex items-center justify-between">
<div className="space-y-1">
<div className="flex items-center gap-3">
<Skeleton className="h-9 w-56" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
<Skeleton className="h-4 w-32" />
</div>
<Skeleton className="h-9 w-24" />
</div>
{/* Quick stats cards */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Skeleton className="h-[100px]" />
<Skeleton className="h-[100px]" />
<Skeleton className="h-[100px]" />
</div>
{/* Site information card */}
<Skeleton className="h-[240px]" />
</div>
)
}Notice how the skeleton mirrors the exact layout: the same flex container, the same grid with three columns, and height values that match the real components. This gives users a stable visual frame while data loads.
When to Add loading.tsx
Add a loading.tsx file when the page's Server Component calls any async data function (for example, getSiteById, getAssetsBySite). If the page renders entirely from client-side React Query hooks, loading.tsx is not needed since the component itself manages loading states via isLoading / isPending.
Suspense Streaming Architecture
Next.js App Router supports streaming: the server can send the page shell immediately and stream in data-dependent sections as they resolve. This is implemented using React Suspense boundaries in layouts.
The Critical Rule: Layouts Must Not Block
If a layout component performs async data fetching directly, it blocks the entire subtree from rendering -- including loading.tsx files in child routes. This causes the navigation to appear frozen.
The correct pattern wraps data-dependent content in a Suspense boundary with a skeleton fallback:
import { Suspense } from "react"
export default async function SiteLayout({ children, params }) {
const { id } = await params
return (
<SiteLayoutShell
siteId={id}
siteNav={
<Suspense fallback={<SiteNavSkeleton />}>
<SiteNavAsync siteId={id} />
</Suspense>
}
>
{children}
</SiteLayoutShell>
)
}In this pattern:
SiteLayoutShellrenders synchronously (it only needs the site ID from URL params).SiteNavAsyncis an async Server Component that fetches the site name, connectors list, and other nav data. It is wrapped inSuspense, so it streams in after the shell.{children}renders immediately, which means the child page'sloading.tsxshows without waiting for the nav data.
Never await a data fetch at the top level of a layout component. Always wrap async data needs in a Suspense boundary. Otherwise, the entire layout (including all child loading states) will be blocked until the fetch completes.
Streaming Timeline
t=0ms Layout shell + loading.tsx render (instant)
t=50ms Child page loading.tsx visible to user
t=200ms Sidebar nav data streams in (replaces SiteNavSkeleton)
t=400ms Page data resolves (replaces loading.tsx with actual content)Without Suspense streaming, the user would see nothing until t=400ms.
Real-Time Data with SSE
For data that changes continuously (for example, live telemetry readings), the platform uses Server-Sent Events (SSE) combined with React Query cache updates.
The useReadingsStream hook opens an EventSource connection to the server and updates the React Query cache on each event:
export function useReadingsStream({ assetId, enabled }) {
const queryClient = useQueryClient()
useEffect(() => {
const es = new EventSource(
`/api/readings/stream?assetId=${encodeURIComponent(assetId)}`
)
es.addEventListener("reading", (event) => {
const reading = JSON.parse(event.data)
// Update React Query cache in-place
queryClient.setQueryData(
["readings", "latest", assetId],
(prev) => {
if (!prev) return [reading]
const idx = prev.findIndex(r => r.variableKey === reading.variableKey)
if (idx >= 0) {
const updated = [...prev]
updated[idx] = reading
return updated
}
return [...prev, reading]
}
)
})
return () => es.close()
}, [assetId, enabled])
}This pattern means any component using useLatestReadings(assetId) automatically receives live updates without additional polling -- the SSE stream pushes updates directly into the shared React Query cache.
Anti-Patterns to Avoid
N+1 HTTP Requests
Fetching data per item in a loop causes a cascade of HTTP requests that slows page loads dramatically.
// BAD: N+1 requests
const sites = await getSites()
for (const site of sites) {
const assets = await getAssetsBySite(site.id) // 1 request per site
}
// GOOD: Use a batch endpoint or fetch all at once
const assets = await getAssets({ siteId: selectedSiteId })For pages that need to display data from multiple entities, use batch endpoints (for example, getAssetTreeNodes returns slim tree nodes in a single request instead of fetching full asset details individually) or fetch at the list level and filter client-side.
Blocking Layouts
An async layout without Suspense boundaries blocks all child routes from rendering.
// BAD: Blocks loading.tsx in all child routes
export default async function SiteLayout({ children, params }) {
const { id } = await params
const site = await getSiteById(id) // Blocks everything
return (
<div>
<SiteNav site={site} />
{children}
</div>
)
}
// GOOD: Wrap async content in Suspense
export default async function SiteLayout({ children, params }) {
const { id } = await params
return (
<SiteLayoutShell siteId={id}
siteNav={
<Suspense fallback={<SiteNavSkeleton />}>
<SiteNavAsync siteId={id} />
</Suspense>
}
>
{children}
</SiteLayoutShell>
)
}Missing loading.tsx
Without a loading.tsx file, navigation to a page with server-side data fetching appears to freeze -- the browser shows no visual feedback while the server processes the request. Always add a loading skeleton for pages with async data.
Client-Side Fetching for Server-Available Data
Using React Query to fetch data that is available at request time adds unnecessary latency. The browser must first load JavaScript, then make a client-side fetch, then render.
// BAD: Fetching site list client-side when it could be server-rendered
function SiteListPage() {
const { data: sites, isLoading } = useQuery({
queryKey: ["sites"],
queryFn: () => fetch("/api/sites").then(r => r.json()),
})
if (isLoading) return <Skeleton />
return <SiteTable sites={sites} />
}
// GOOD: Fetch in a Server Component
async function SiteListPage() {
const sites = await getSites()
return <SiteTable sites={sites} />
}Reserve React Query for data that genuinely needs client-side management: polling, real-time updates, user-driven filters, or data that changes independently of mutations.
Unbounded Queries
Fetching all records without pagination or field selection can return enormous payloads. For example, a site with 10,000 assets returns approximately 5.4 MB of full asset objects. The platform provides slim endpoints for this purpose:
// BAD: Fetches full asset objects (5.4 MB for 10K assets)
const assets = await getAssetsBySite(siteId)
// GOOD: Fetches only the 6 fields needed for tree rendering (1.2 MB for 10K assets)
const treeNodes = await getAssetTreeNodes(siteId)When building new features, consider what fields the UI actually needs and whether a dedicated slim endpoint would be more appropriate than the full entity response.