Recommended resources
The freely available resources this lesson will direct you to.
Hello! Welcome back to our series on advanced optimization for your Tanstack Start application.
In our last lesson, we focused on caching the final HTML output of our server using Cache-Control headers. This is a powerful way to have browsers and CDNs serve entire pages without ever hitting our server.
Today, we shift our focus from caching the response to caching the data used to build that response. Our goal is to implement a server-side in-memory cache for frequently requested data in route loaders. This technique is crucial for reducing the load on your database or external APIs and for speeding up the Time to First Byte (TTFB) by avoiding redundant data fetching on the server itself.
We will explore two distinct but complementary types of server-side in-memory caching:
- Request-Scoped Caching: How to avoid fetching the same data multiple times within a single page render.
- Cross-Request Caching: How to cache data that is common to all users and persists across multiple requests.
1. Request-Scoped Caching: Deduplication with React.cache
Imagine a scenario where your page's header, a sidebar, and the main content area all need to know the current user's details. Without caching, each component might independently trigger a fetch for the user's session, resulting in three identical API calls or database queries for a single page load. This is inefficient.
React provides a simple and elegant solution for this exact problem: the React.cache function. It wraps an async function and memoizes its result for the duration of a single server-side render pass. If the same function is called again with the same arguments during that render, it receives the cached promise instead of re-executing.
The following video, though demonstrated in a Next.js context, explains this concept perfectly, as cache is a core React feature.
This is how I Made My Next.js Application 10x Faster
This clip from Jan Marshal's video on Next.js performance demonstrates how to use React's cache function to prevent redundant data fetches within a single render.
Watch from 26:49 to 29:31. Pay close attention to the problem of calling requireUser multiple times and how wrapping it in cache solves the issue.
Implementation in Tanstack Start
You can apply this pattern directly to any data-fetching function used in your Tanstack Start application. Let's say you have a function to fetch the current user's session.
Before (Inefficient):
// src/lib/auth.ts
export async function getCurrentUser(request: Request) {
console.log('Fetching user session...');
const sessionId = getSessionIdFromCookie(request.headers.get('Cookie'));
if (!sessionId) return null;
// Imagine this is a database or API call
const user = await db.users.find({ sessionId });
return user;
}
If you call getCurrentUser from multiple loaders or components during a single request, you'll see "Fetching user session..." logged multiple times.
After (Efficient with React.cache):
// src/lib/auth.ts
import { cache } from 'react';
export const getCurrentUser = cache(async (request: Request) => {
console.log('Fetching user session...');
const sessionId = getSessionIdFromCookie(request.headers.get('Cookie'));
if (!sessionId) return null;
const user = await db.users.find({ sessionId });
return user;
});
Now, no matter how many times getCurrentUser is called during a single server render, the underlying logic will only execute once. This is the ideal way to handle data that is needed in multiple places but is scoped to a single request (like user data).
2. Cross-Request Caching: A True Server-Side Cache
Request-scoped caching is great, but what about data that is not user-specific and changes infrequently? For example:
- A list of product categories for a navigation menu.
- Global application settings.
- A list of tags for blog posts.
Fetching this data from the database on every single request from every single user is wasteful. This is where a cross-request, in-memory server cache comes in. We want to fetch the data once, store it in memory on the server for a defined period (e.g., 5 minutes), and serve all subsequent requests from this cache.
The Next.js framework provides a utility for this, formerly called unstable_cache. While Tanstack Start doesn't have an equivalent built-in, the pattern it demonstrates is universal and highly valuable.
Next.js App Router Caching: Explained!
This video from Vercel explains the concept of a persistent, server-side cache that lives across requests, using the unstable_cache function as an example. This is the pattern we want to replicate.
Watch from 07:39 to 10:11. Focus on how unstable_cache is used to wrap a database query, making its result available to subsequent requests, and the concepts of tagging and revalidation.
Implementing a Simple Cache in Tanstack Start
Since we don't have a pre-built utility, let's create a simple one. For a production application, you might use a robust library like node-cache, but a Map is sufficient to illustrate the principle.
Here is a simple implementation of a time-based in-memory cache:
// src/lib/server-cache.ts
// This cache will live in the server's memory across requests.
const cache = new Map<string, { value: any; expires: number }>();
interface CacheOptions {
key: string;
ttlSeconds: number; // Time-to-live in seconds
}
export async function serverCache<T>(
fn: () => Promise<T>,
options: CacheOptions,
): Promise<T> {
const { key, ttlSeconds } = options;
const cachedItem = cache.get(key);
if (cachedItem && cachedItem.expires > Date.now()) {
console.log(`[CACHE HIT] for key: ${key}`);
return cachedItem.value as T;
}
console.log(`[CACHE MISS] for key: ${key}`);
const newValue = await fn();
const expires = Date.now() + ttlSeconds * 1000;
cache.set(key, { value: newValue, expires });
// Clean up expired entries occasionally
// In a real app, you'd want a more robust cleanup strategy.
for (const [k, v] of cache.entries()) {
if (v.expires < Date.now()) {
cache.delete(k);
}
}
return newValue;
}
Now, you can use this utility in a route loader to cache frequently accessed, non-user-specific data.
// src/routes/products.tsx
import { createFileRoute } from '@tanstack/react-router';
import { serverCache } from '~/lib/server-cache';
// This function fetches data that rarely changes.
async function fetchProductCategories() {
console.log('Fetching product categories from DB...');
// Imagine a slow DB query here
await new Promise(resolve => setTimeout(resolve, 500));
return ['Electronics', 'Books', 'Home Goods'];
}
export const Route = createFileRoute('/products')({
loader: async () => {
const categories = await serverCache(fetchProductCategories, {
key: 'product-categories',
ttlSeconds: 300, // Cache for 5 minutes
});
// ... fetch other product data ...
const products = await fetchProducts();
return { categories, products };
},
// ... component
});
With this setup, the first user to visit /products will trigger fetchProductCategories, and the result will be cached. Any other user visiting within the next 5 minutes will get the categories instantly from the in-memory cache, significantly improving TTFB and reducing database load.
3. Clarification: Tanstack Router's Built-in Cache
You might be wondering about the "built-in SWR Caching" that the Tanstack Router documentation mentions. Does it provide this cross-request server cache?
The short answer is no. Let's clarify its purpose.
Data Loading | TanStack Router React Docs
The TanStack Router documentation describes its built-in caching mechanism. Let's read it to understand its design and intended use case.
First, read the section 'To Router Cache or not to Router Cache?'. Then, read 'Dependency-based Stale-While-Revalidate Caching' and the 'Key options' subsection. Note the pros/cons and the focus on SWR and client-side behavior.
As the documentation shows, the router's cache is a sophisticated stale-while-revalidate (SWR) system. It's primarily designed to improve the user experience during client-side navigation. When you navigate back to a previously visited route, it can show the stale data instantly while refetching in the background.
On the server, to ensure strict data isolation and prevent leaking one user's data to another, a new, isolated cache instance is created for every request. This is a critical security and design principle in SSR frameworks.
The Tanstack Query documentation on SSR memory usage confirms this philosophy.
Server Rendering & Hydration | TanStack Query React Docs
Although this is from the React Query docs, the principle of data isolation on the server is fundamental to the entire Tanstack ecosystem. This section explains the memory model for server-side rendering.
Read the subsection titled 'High memory consumption on server'. It explicitly states that the cache is isolated for each client and is preserved for the duration of the request.
In summary:
- Tanstack Router Cache: Primarily for client-side navigation. On the server, it is request-scoped. Using
staleTimein a route loader configures client behavior, not a cross-request server cache. - Our
serverCacheutility: A true cross-request server-side cache for shared, non-user-specific data. React.cache: A request-scoped cache for deduplicating fetches within a single server render.
A high-performance application uses all of these tools for their intended purpose.
Conclusion
In this lesson, we've dissected the layers of in-memory caching available in a modern SSR application. You now have a clear framework for deciding which caching strategy to use for different types of data.
Key Takeaways:
- Server-side data caching is about avoiding redundant data fetching before HTML is rendered, complementing the HTTP caching of the final response.
- Use
React.cacheto deduplicate function calls that are specific to a single user's request (e.g.,getCurrentUser). This is request-scoped caching. - Implement a custom, cross-request cache (like our
serverCacheutility) for data that is public, shared, and changes infrequently (e.g.,fetchProductCategories). - The built-in Tanstack Router cache is designed for client-side SWR and is intentionally request-scoped on the server to prevent data leaks.
Next Up:
We've just implemented two powerful performance optimizations. But how do we know they're working? How do we find the next bottleneck? In our next lesson, we will learn how to profile server-side rendering performance to identify and resolve bottlenecks, giving you the tools to measure the impact of your optimizations and guide your future efforts.