Caching and Expiry Strategies: An In-Depth Guide

An extensive exploration of caching and expiry techniques in frontend development, with a focus on achieving optimal performance while maintaining data freshness. Discusses various caching layers, expiry mechanisms, and caching practices in large-scale applications like GitHub, Stripe, and Vercel.

ShareShare

Caching and Expiry Strategies: An In-Depth Guide

Caching is an integral part of modern web applications, playing a pivotal role in enhancing performance and improving user experience. However, it can be a double-edged sword — while a well-implemented cache can make your app feel instantaneous and efficient, missteps can lead to stale or incorrect data being served to your users. Thus, striking a balance between speed and freshness using effective caching and expiry strategies is critical.

In this article, we will delve into various aspects of frontend caching, including:

  • Different layers and types of caches
  • Various expiry mechanisms, such as Time-To-Live (TTL) and stale-while-revalidate (SWR)
  • Cache control via HTTP headers and libraries
  • Considerations for client-side and server-side caching
  • Real-world caching strategies employed by GitHub, Stripe, and Vercel

The Necessity of Caching

Caching offers several advantages, including:

  • Reduction in API calls and network traffic, which can significantly enhance application response times and user experience.
  • Prevention of UI flicker during data refetch, providing a smoother user experience.
  • Support for offline access, thus improving app availability.
  • Conservation of device battery and network bandwidth, crucial for mobile users.

However, caching without a proper expiry mechanism can be counterproductive, as it can result in serving outdated or incorrect data indefinitely.


Caching Layers in Frontend Development

Frontend caching can be divided into several layers, each with its own expiration model, storage mechanism, and invalidation logic:

  1. Memory cache: This is an in-memory object/state that is incredibly fast but volatile, meaning it doesn’t survive page reloads or tab closures.
  2. Persistent cache: This utilizes browser storage mechanisms like IndexedDB or localStorage, providing a long-lived cache that survives across sessions.
  3. HTTP cache: This is a browser-native mechanism leveraged by the Cache-Control HTTP header.
  4. Library cache: Libraries like SWR, React Query, and Apollo Client provide built-in caching mechanisms with sophisticated control options.
  5. Edge cache: This involves the use of a Content Delivery Network (CDN) or server prefetching, caching data closer to the user for faster delivery.

Expiry Strategies

Expiry strategies define when and how cached data becomes stale. Two commonly used strategies are:

Time-To-Live (TTL)

In this strategy, data is considered expired after a fixed time interval. Below is a JavaScript example illustrating a simple TTL check:

const cached = getFromCache();
if (cached && Date.now() - cached.timestamp < 1000 * 60 * 5) { // 5 minutes TTL
  return cached.value;
}

Stale-While-Revalidate (SWR)

This strategy allows the application to serve stale data instantly while fetching fresh data in the background. It is particularly useful when data isn’t critical to be 100% up-to-date, such as dashboards or lists. Here is an example using the SWR library in a React application:

const { data } = useSWR("/api/user", fetcher, {
  refreshInterval: 30000, // Refresh data every 30 seconds
});

Cache Control via HTTP Headers

HTTP headers play a crucial role in dictating the cache behavior of browsers. Here's an example of HTTP headers for caching:

Cache-Control: public, max-age=60, stale-while-revalidate=30
ETag: "abc123"

The Cache-Control header includes directives like max-age, which defines how long the data can be considered fresh, and stale-while-revalidate, which allows the use of stale content while fresh content is being fetched in the background. The ETag header provides a unique ID for the content, allowing the browser to revalidate the data on the next request if it has expired.


Manual Expiry with React Query

React Query provides control over cache expiry with the staleTime and cacheTime options in the useQuery hook, as shown below:

useQuery("user", fetchUser, {
  staleTime: 1000 * 60 * 5, // Data is fresh for 5 minutes
  cacheTime: 1000 * 60 * 10, // Data is removed from cache after 10 minutes of inactivity
});

Cache Invalidation

Cache invalidation — deciding when and how to update or remove outdated data — is arguably the most challenging aspect of caching. Some strategies include:

  • Revalidating data when the page regains focus or after tab restoration.
  • Invalidating data after mutations, which can be done using methods provided by caching libraries, such as queryClient.invalidateQueries in React Query.
  • Using cache keys and query parameters to maintain per-resource freshness.
  • Implementing periodic TTL expiry, which triggers automatic re-fetching of data.

Real-World Caching Practices

Let's look at how some large-scale applications implement their caching strategies:

GitHub

GitHub employs ETag-based revalidation and leans towards using stale cache for performance. It always revalidates data when the page regains focus.

Stripe

Stripe caches static resources like prices and metadata. However, authenticated data bypasses CDN caching and is stored only in session-local cache.

Vercel

Vercel uses a combination of CDN and client-side SWR for caching. It also prefetches data on hover and keeps the cache warm across navigation events.


Anti-Patterns in Caching

While implementing caching, it is important to avoid certain anti-patterns:

  • Not using caching at all, resulting in slow performance every time.
  • Using infinite cache without an expiry mechanism, leading to stale data being served indefinitely.
  • Relying on localStorage without implementing TTL logic.
  • Refetching data on every page load, undermining the benefits of caching.
  • Mutating cached data without invalidating the cache, leading to inconsistency.

Conclusion: Achieving a Balance - Fast and Fresh

Caching can indeed feel like performance magic, with its ability to make your application feel fast and responsive. However, it's crucial to control it with appropriate expiry strategies, creating a balance that results in serving data that is both quick and fresh.

In essence, a well-implemented cache should be:

  • Fast, serving data quickly to enhance user experience.
  • Intelligent about staleness, understanding when to invalidate and refresh data.
  • Respectful of user trust, ensuring the data served is accurate and reliable.

Remember, while no user enjoys staring at a loading spinner, they also don't appreciate seeing outdated information. So, cache fast, but expire wisely.

Subscribe for Deep Dives

Get exclusive in-depth technical articles and insights directly to your inbox.

about

Ehsan Hosseini

Ehsan Hosseini

me [at] ehosseini [dot] info

Staff Software Engineer and Tech Lead with a track record of leading high-performing engineering teams and delivering scalable, end-to-end technology solutions. With experience across web, mobile, and backend systems, I help companies make smart architectural decisions, scale efficiently, and align technical strategy with business goals.

© 2025 Ehsan Hosseini. All rights reserved.