This article is focused on caching in Javascript and React.
- Most of the concepts in this article are applicable to other languages and frameworks.
- Javascript is familiar to many and intertwined with the browser caching APIs, so it is a good place to start.
- In this article, I am not going into too much detail on browser cache-related APIs and will leave that to a future article.
Also see:
Ultimate Caching Guide 1: Overview and Strategies
Javascript caching
Using caching techniques at the application level can improve performance and reduce the load on the server.
There are a number of caching methods available to Javascript developers to improve performance.
Closure caching
Returning a closure from a function that caches the result of a computation.
function calculateSum() {
let cache = 0
return function (num) {
if (num === undefined) {
return cache
} else {
cache += num
return cache
}
}
}
const sum = calculateSum()
console.log(sum(2)) // Output: 2
console.log(sum(4)) // Output: 6
console.log(sum()) // Output: 6 (cached value)
console.log(sum(5)) // Output: 11
console.log(sum()) // Output: 11 (cached value)
Data Structure Caching
Similar caching can be done with various data structures, such as arrays, objects, Maps, Sets, etc.
Basic object cache, usually placed inside a function:
const cache = {}
function getFromCache(key) {
return cache[key]
}
function addToCache(key, value) {
cache[key] = value
}
const complexCalculation = (a, b, c) =>
Math.sqrt(Math.pow(a, 2) + Math.pow(b, 2) + Math.pow(c, 2)) /
(Math.pow(Math.E, a) + Math.pow(Math.E, b) + Math.pow(Math.E, c))
addToCache('mathResult', complexCalculation(1, 2, 3))
console.log(getFromCache('mathResult')) // Output: '0.12392517788687459'
or
Set:
const cache = new Set()
function cachedData(data) {
if (cache.has(data)) {
console.log('Data is cached')
return data
}
fetchData(data)
cache.add(data)
return data
}
// fetchData(data)
function fetchData(data) {
return data
}
const data = { "a": 1, "b": 2 };
const result1 = cachedData(data)
console.log(result1)
const result2 = cachedData(data)
console.log(result2)
Memoization
Object caching is focused on the data structures used by a program, while memoization is focused on the results of a function.
function memoize(fn) {
const cache = {}
return function (...args) {
const key = JSON.stringify(args)
if (cache[key]) {
console.log('Returning from cache...')
return cache[key]
}
const result = fn.apply(this, args)
cache[key] = result
return result
}
}
function expensiveComputation(x, y) {
console.log('Performing expensive computation...')
return x + y
}
const memoizedComputation = memoize(expensiveComputation)
console.log(memoizedComputation(2, 3)) // Performing expensive computation... 5
console.log(memoizedComputation(2, 3)) // 5 (returned from cache)
Memory-based Storage API (eg. SessionStorage)
It's simple and straightforward to use the Storage API to cache data in the browser.
SessionStorage and LocalStorage are similar in terms of functionality, but LocalStorage persists data across browser sessions.
- there is a separate sessionStorage for each tab or window.
- When the tab or window is closed, the web browser ends the session and clears sessionStorage.
- it's specific to the protocol of the page.
// Store data in SessionStorage
sessionStorage.setItem('myData', JSON.stringify({ foo: 'bar' }));
// Retrieve data from SessionStorage
const data = JSON.parse(sessionStorage.getItem('myData'));
Disk-based (LocalStorage, IndexDB)
LocalStorage is a key-value store persisting data across browser sessions.
https://developer.mozilla.org/en-US/docs/Web/API/Storage
IndexDB is a more complex API for storing data in the browser and allows more complex querying and indexing of structured data. It can store data in different formats, including binary data, and provides asynchronous APIs for better performance. IndexedDB is asynchronous, while LocalStorage is synchronous.
https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API
SessionStorage is cleared when the browser is closed. LocalStorage is cleared when the user clears their browser cache.
const cacheKey = 'myData';
let cachedData = JSON.parse(localStorage.getItem(cacheKey));
if (!cachedData) {
// Data is not in cache, fetch it from the server
fetch('/my/data').then(response => {
// Cache the response data
response.json().then(data => {
localStorage.setItem(cacheKey, JSON.stringify(data));
});
});
} else {
// Use cached data
console.log('Using cached data:', cachedData);
}
WebWorker
WebWorkers can be used for background caching and to store data in memory, basically non-blocking interaction with the app's main thread with 'postMessage'. Also it can be used for offline functionality.
// Listen for requests from the main thread
self.addEventListener('message', event => {
const { url, cacheKey } = event.data;
// Check if the data is cached in localStorage
const cachedData = localStorage.getItem(cacheKey);
if (cachedData) {
// If cached data is found, send the data back to the main thread
self.postMessage(JSON.parse(cachedData));
} else {
// If cached data is not found, fetch the data from the API
fetch(url)
.then(response => response.json())
.then(data => {
// Store the fetched data in localStorage for future use
localStorage.setItem(cacheKey, JSON.stringify(data));
// Send the fetched data back to the main thread
self.postMessage(data);
})
.catch(error => {
// If an error occurs, send an error message back to the main thread
self.postMessage({ error: error.message });
});
}
});
Service Worker
- https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorker
- https://web.dev/offline-cookbook/#serving-suggestions
- https://dev.to/jonchen/service-worker-caching-and-http-caching-p82
- https://web.dev/service-worker-lifecycle/
- https://create-react-app.dev/docs/making-a-progressive-web-app/#offline-first-considerations
Network Only, Network first, then cache, Stale-While-Revalidate, Cache First,fall back to network, Cache Only
The Service Worker is a bit more versatile than the WebWorker and will cache the specified files when it is installed, and intercept fetch requests to return cached responses if available. It will also clean up old caches when the new version of the Service Worker is activated.
You can think of it like a local reverse proxy server that intercepts requests and returns cached responses if available.
// Install the Service Worker and cache some files
self.addEventListener('install', event => {
event.waitUntil(
caches.open('my-cache').then(cache => {
return cache.addAll([
'/',
'/index.html',
'/styles.css',
'/script.js',
'/image.png'
]);
})
);
});
// Intercept fetch requests and return cached responses if available
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(response => {
return response || fetch(event.request);
})
);
});
// Clean up old caches when activating the new version of the Service Worker
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.filter(cacheName => {
return cacheName !== 'my-cache';
}).map(cacheName => {
return caches.delete(cacheName);
})
);
})
);
});
Libraries
lru-cache
https://www.npmjs.com/package/lru-cache
- provides a caching mechanism with a least recently used (LRU) eviction policy.
- The LRU policy means that when the cache reaches its maximum size, the least recently used items are removed from the cache to make room for new items.
- Optimize memory usage and performance by ensuring that the most frequently accessed items remain in the cache, while less frequently accessed items are evicted.
node-cache-manager
https://www.npmjs.com/package/cache-manager
React (other SPAs have similar options)
Memoization
- React provides a built-in utility called React.memo() that can be used to memoize functional components. However, modern usage of React Hooks makes it easier and is the common usage.
- useMemo
- useCallback
A code example is below:
The two hooks are similar, but they have different use cases. useMemo is used to memoize the result of a function, while useCallback is used to memoize the function itself.
- useMemo is used to memoize the result of the calculateSum function.
- useCallback is used to memoize the calculateSum function. The calculateSum function will only be re-created if one of these values changes.
By using useCallback and useMemo, we're able to optimize our code and avoid unnecessary re-renders.
import React, { useState, useMemo, useCallback } from 'react';
function App() {
const [num1, setNum1] = useState(0);
const [num2, setNum2] = useState(0);
const calculateSum = useCallback(() => {
console.log("Calculating sum...");
return num1 + num2;
}, [num1, num2]);
const sum = useMemo(() => calculateSum(), [calculateSum]);
return (
<div>
<h1>Sum: {sum}</h1>
<input type="number" value={num1} onChange={(e) => setNum1(parseInt(e.target.value))} />
<input type="number" value={num2} onChange={(e) => setNum2(parseInt(e.target.value))} />
</div>
);
}
export default App;
Query caching
Another important React caching technique is query caching. Query caching is used to store the results of a query so that the query doesn't have to be executed again if the same query is made. This can be useful for reducing the number of API requests made to a server.
Most popular libraries have their own query caching mechanism.
This chart shows some of the varieties available:
https://redux-toolkit.js.org/rtk-query/comparison
- Caching in Apollo Client
- TanStack Query/React Query QueryCache
- RTK Query - Redux Toolkit
- SWR - React Hooks for Data Fetching
PureComponent (deprecated, but still in some codebases)
- PureComponent is a subclass of the React.Component that implements a shallow comparison of props and state to determine if a re-render is necessary. This can improve performance by preventing unnecessary re-renders.
https://beta.reactjs.org/reference/react/PureComponent
React.lazy() and Suspense
- React.lazy() is a newer feature introduced in React 16.6 that allows for lazy loading of components. Suspense is a component that allows you to handle loading states and fallbacks.
It's mainly use for code-splitting, but could facilitate caching since it dynamically loads components of code and stores in memory to be re-used in other components.
Context API
The Context API provides a way to pass data through the component tree without having to pass props down manually at every level.
By using context, we are effectively caching props and can avoid unnecessary re-renders and improve performance.
import React, { useContext } from 'react';
// Define a context with a default value
const MyContext = React.createContext('default value');
// A component that uses the context
function MyComponent() {
// Get the value of the context using useContext hook
const contextValue = useContext(MyContext);
return (
<div>
<p>Context value: {contextValue}</p>
</div>
);
}
// A parent component that provides the context value
function App() {
return (
<MyContext.Provider value="hello world">
<MyComponent />
</MyContext.Provider>
);
}
Next.js and Server-side rendering
Server-side rendering is the process of rendering the initial HTML and React components on the server before sending it to the client. This can improve performance by reducing the time it takes for the client to receive and render the content.
Next.js provides several built-in caching mechanisms that allow pages to be cached in memory or on disk, reducing the need to re-render pages for subsequent requests.
Static file caching: there are static files for each page served directly from disk for subsequent requests. These files are cached in memory by default, and can also be cached by a reverse proxy server or CDN.
-
Incremental Static Regeneration (ISR): Next.js supports a feature called ISR, which allows pages to be revalidated and regenerated incrementally at a specified interval or on demand.
-
Server-side rendering (SSR) caching: Next.js provides an API for caching the output of server-side rendering (SSR) for a given request. Cache-control headers can be used to control how long the cached output is valid for.
Memory caching: Next.js provides an in-memory cache that can be used to store arbitrary data, such as database queries or API responses.
-
ETags: Next.js supports the use of HTTP ETags to cache responses from the server.
-
Minimum Cache TTL
Memoized selectors
Redux is a popular state management library used with React.
Memoized selectors are functions that compute derived data from the Redux store (stored state data in memory).
Redux and RTK provide built-in support for memoized selectors through the reselect library - https://github.com/reduxjs/reselect
By memoizing these functions, you can avoid re-computing the same data over and over again.
This is basically extending memoization to the Redux framework and state management data structures.
We've looked at how caching can be used at the application level in Javascript and React to improve performance.
There are quite a few choices to make when it comes to caching, and it's important to understand the tradeoffs and use cases for each.
Some like useMemo and useCallback are used to optimize the performance of React components.
Others like Apollo Client and React Query are used to cache API requests and reduce the number of requests made to a server.
While some like Next.js and SSR are used to cache the output of server-side rendering.
Also, in the first article in this series we already examined some of the main caching patterns which can be applied across all types of services.
Next article we'll continue to trace our caching options through the cloud and into large-scale distributed systems.
Also see:
Ultimate Caching Guide 1: Overview and Strategies
and
Top comments (1)
Thanks CSJ - that has opened my eyes to quite a few things I simply didn't know about.
I'm particularly interested in Next.js caching just now, but their own docs are aggressively technical,. Your high-level intro has been invaluable.
Keep up the good work! Regards, MartinJ