End-to-End Caching in Next.js: React Query (UI) → SSR with memory-cache
Author: Regal Singh
Last updated: 2025-10-26
Category: Web Performance / Caching / Next.js / React Query
End-to-End Caching in Next.js: React Query (UI) → SSR/Route Handlers (Node) with memory-cache
Abstract
Caching in Next.js is not one thing. It is server work, client hydration, and real user traffic. If you only cache on one side, you still pay for duplication somewhere else. This post shows a simple, ops-oriented stack: memory-cache on the server and React Query on the client. The goal is fewer backend calls, tighter TTFB, and predictable behavior under load.
In many modern web applications, it’s common for both the server and the client to perform the same operations repeatedly. By introducing caching at both levels — using tools like memory-cache on the server and React Query on the client — we can reduce redundant processing and improve efficiency across the stack.
Problem framing: why caching needs multiple layers
A single cache does not stop all duplication:
Route handlers still hit downstream services per request. SSR often re-fetches what the client will fetch again. Client-side navigation can replay calls without guardrails. You need a server cache for SSR/route handlers and a client cache for UI state and hydration.
Where duplication happens in Next.js SSR + client hydration
The common double-fetch flow:
Server renders the page and fetches data for HTML. Client hydrates and runs the same fetch again. If you do not hydrate the client cache, the browser repeats the server call and you pay twice.
Layer 1: Server caching with memory-cache in Route Handlers
Use a TTL cache for the Node runtime. Keep it small and predictable.
cached() helper with TTL
// app/lib/cache.ts
import cache from "memory-cache";
type CacheValue<T> = { value: T; expiresAt: number };
export function cached<T>(
key: string,
ttlMs: number,
getter: () => Promise<T>
): Promise<T> {
const cachedValue = cache.get(key) as CacheValue<T> | null;
const now = Date.now();
if (cachedValue && cachedValue.expiresAt > now) {
return Promise.resolve(cachedValue.value);
}
return getter().then((value) => {
cache.put(key, { value, expiresAt: now + ttlMs }, ttlMs);
return value;
});
}
Example route handler
// app/api/products/[id]/route.ts
import { cached } from "@/app/lib/cache";
export async function GET(
_req: Request,
{ params }: { params: { id: string } }
) {
const key = `product:${params.id}`;
const ttlMs = 30_000;
const product = await cached(key, ttlMs, async () => {
const res = await fetch(`https://api.example.com/products/${params.id}`);
if (!res.ok) {
throw new Error("Upstream error");
}
return res.json();
});
return Response.json(product);
}
Limitations Per-instance only (no shared state across pods). Cache resets on restart. Horizontal scaling means each instance warms separately.
Layer 2: Client caching with React Query
Use React Query to avoid redundant client calls and manage freshness.
// app/components/ProductClient.tsx
"use client";
import { useQuery } from "@tanstack/react-query";
async function fetchProduct(id: string) {
const res = await fetch(`/api/products/${id}`);
if (!res.ok) throw new Error("Request failed");
return res.json();
}
export function ProductClient({ id }: { id: string }) {
const { data, isLoading } = useQuery({
queryKey: ["product", id],
queryFn: () => fetchProduct(id),
staleTime: 30_000,
gcTime: 5 * 60_000,
refetchOnWindowFocus: false,
});
if (isLoading) return <div>Loading...</div>;
return <pre>{JSON.stringify(data, null, 2)}</pre>;
}
The bridge: SSR prefetch + React Query hydration
Prefetch on the server, dehydrate, and hydrate on the client.
// app/products/[id]/page.tsx
import { QueryClient, dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { ProductClient } from "@/app/components/ProductClient";
async function fetchProduct(id: string) {
const res = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/products/${id}`);
if (!res.ok) throw new Error("Request failed");
return res.json();
}
export default async function ProductPage({ params }: { params: { id: string } }) {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ["product", params.id],
queryFn: () => fetchProduct(params.id),
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ProductClient id={params.id} />
</HydrationBoundary>
);
}
Real Ops: guardrails and observability
Make caching visible and resilient.
In-flight dedupe (server) Prevent thundering herd with a promise map.
// app/lib/inflight.ts
const inflight = new Map<string, Promise<any>>();
export async function dedupe<T>(key: string, task: () => Promise<T>): Promise<T> {
if (inflight.has(key)) {
return inflight.get(key) as Promise<T>;
}
const promise = task().finally(() => inflight.delete(key));
inflight.set(key, promise);
return promise;
}
Cache hit/miss counters + stats endpoint
// app/lib/cacheStats.ts
import cache from "memory-cache";
let hits = 0;
let misses = 0;
export function recordHit() { hits += 1; }
export function recordMiss() { misses += 1; }
export function getCacheStats() {
const keys = cache.keys() as string[];
const size = keys.length;
const approxMemoryBytes = size * 120; // rough estimate
return { hits, misses, keys: size, approxMemoryBytes };
}
// app/api/cache/stats/route.ts
import { getCacheStats } from "@/app/lib/cacheStats";
export async function GET() {
return Response.json(getCacheStats());
}
Structured log format recommendation
Use a simple JSON schema:
{
"ts": "2026-01-27T12:00:00Z",
"level": "info",
"event": "cache_hit",
"cache_key": "product:123",
"ttl_ms": 30000,
"route": "/api/products/123"
}
TTL strategy by data type (hot/warm/cold)
Keep TTL aligned with volatility.
Data class Example TTL range Hot inventory, error rate 5s–30s Warm catalog data 30s–5m Cold historical stats 5m–1h Decision matrix table Data type Volatility SSR cache TTL React Query staleTime Invalidation approach Notes Product detail Medium 30s 30s Tag-based invalidation Good for detail pages Inventory High 5s 5s Push invalidation Consider short TTL Pricing Medium 60s 30s Version bump Avoid stale prices User profile Medium 10s 10s User action invalidation Avoid cross-user leakage Analytics summary Low 5m 5m Scheduled refresh Safe to cache longer
Practical pitfalls
Caching errors: do not cache failures unless you define a short error TTL. Caching per-user sensitive data: never share keys across users. Unbounded key growth: enforce key limits or TTL caps. Multi-instance inconsistency: caches diverge under scale. Stale data risk vs performance: align TTL with business impact.
Minimal evaluation guidance
What to measure:
p95 SSR time backend request reduction cache hit ratio TTFB LCP impact How to validate:
before/after comparison on the same route load test with and without cache log analysis for hits/misses and tail latency
Limitations and next steps
Move to Redis/CDN when you need shared cache across instances. Use Next.js fetch caching, revalidate, and tags for built-in primitives. Tradeoffs: memory cache is fastest but least consistent; shared caches are slower but reliable.
Related blogs
- NLP Foundations Part 3: Why Some Words Matter More
- NLP Foundations Part 2: How Text Becomes Measurable Patterns
- NLP Foundations Part 1: How Machines Begin Reading Text
- Signal vs Noise: A Decision Framework Before Modeling
- Why Graphs Matter Before Modeling: Seeing Noise, Mean, Median, and Variable Relationships
- Statistics & Predictive Modeling: Data Foundations
- Prefetching Static Chunks Across Apps: How It Improves Page Performance
- How Next.js Helps SEO for Google Search