This small battle-tested TypeScript library is a storage-agnostic helper that implements a configurable stale-while-revalidate caching strategy for any functions, for any JavaScript environment.
The library will take care of deduplicating any function invocations (requests) for the same cache key so that making concurrent requests will not unnecessarily bypass your cache.
The library can be installed from NPM using your favorite package manager.
To install via npm
:
npm install stale-while-revalidate-cache
At the most basic level, you can import the exported createStaleWhileRevalidateCache
function that takes some config and gives you back the cache helper.
This cache helper (called swr
in example below) is an asynchronous function that you can invoke whenever you want to run your cached function. This cache helper takes two arguments, a key to identify the resource in the cache, and the function that should be invoked to retrieve the data that you want to cache. (An optional third argument can be used to override the cache config for the specific invocation.) This function would typically fetch content from an external API, but it could be anything like some resource intensive computation that you don't want the user to wait for and a cache value would be acceptable.
Invoking this swr
function returns a Promise that resolves to an object of the following shape:
type ResponseObject = {
/* The value is inferred from the async function passed to swr */
value: ReturnType<typeof yourAsyncFunction>
/**
* Indicates the cache status of the returned value:
*
* `fresh`: returned from cache without revalidating, ie. `cachedTime` < `minTimeToStale`
* `stale`: returned from cache but revalidation running in background, ie. `minTimeToStale` < `cachedTime` < `maxTimeToLive`
* `expired`: not returned from cache but fetched fresh from async function invocation, ie. `cachedTime` > `maxTimeToLive`
* `miss`: no previous cache entry existed so waiting for response from async function before returning value
*/
status: 'fresh' | 'stale' | 'expired' | 'miss'
/* `minTimeToStale` config value used (see configuration below) */
minTimeToStale: number
/* `maxTimeToLive` config value used (see configuration below) */
maxTimeToLive: number
/* Timestamp when function was invoked */
now: number
/* Timestamp when value was cached */
cachedAt: number
/* Timestamp when cache value will be stale */
staleAt: number
/* Timestamp when cache value will expire */
expireAt: number
}
The cache helper (swr
) is also a fully functional event emitter, but more about that later.
import { createStaleWhileRevalidateCache } from 'stale-while-revalidate-cache'
const swr = createStaleWhileRevalidateCache({
storage: window.localStorage,
})
const cacheKey = 'a-cache-key'
const result = await swr(cacheKey, async () => 'some-return-value')
// result.value: 'some-return-value'
const result2 = await swr(cacheKey, async () => 'some-other-return-value')
// result2.value: 'some-return-value' <- returned from cache while revalidating to new value for next invocation
const result3 = await swr(cacheKey, async () => 'yet-another-return-value')
// result3.value: 'some-other-return-value' <- previous value (assuming it was already revalidated and cached by now)
The createStaleWhileRevalidateCache
function takes a single config object, that you can use to configure how your stale-while-revalidate cache should behave. The only mandatory property is the storage
property, which tells the library where the content should be persisted and retrieved from.
You can also override any of the following configuration values when you call the actual swr()
helper function by passing a partial config object as a third argument. For example:
const cacheKey = 'some-cache-key'
const yourFunction = async () => ({ something: 'useful' })
const configOverrides = {
maxTimeToLive: 30000,
minTimeToStale: 3000,
}
const result = await swr(cacheKey, yourFunction, configOverrides)
The storage
property can be any object that have getItem(cacheKey: string)
and setItem(cacheKey: string, value: any)
methods on it. If you want to use the swr.delete(cacheKey)
method, the storage
object needs to have a removeItem(cacheKey: string)
method as well. Because of this, in the browser, you could simply use window.localStorage
as your storage
object, but there are many other storage options that satisfies this requirement. Or you can build your own.
For instance, if you want to use Redis on the server:
import Redis from 'ioredis'
import { createStaleWhileRevalidateCache } from 'stale-while-revalidate-cache'
const redis = new Redis()
const storage = {
async getItem(cacheKey: string) {
return redis.get(cacheKey)
},
async setItem(cacheKey: string, cacheValue: any) {
// Use px or ex depending on whether you use milliseconds or seconds for your ttl
// It is recommended to set ttl to your maxTimeToLive (it has to be more than it)
await redis.set(cacheKey, cacheValue, 'px', ttl)
},
async removeItem(cacheKey: string) {
await redis.del(cacheKey)
},
}
const swr = createStaleWhileRevalidateCache({
storage,
})
Default: 0
Milliseconds until a cached value should be considered stale. If a cached value is fresher than the number of milliseconds, it is considered fresh and the task function is not invoked.
Default: Infinity
Milliseconds until a cached value should be considered expired. If a cached value is expired, it will be discarded and the task function will always be invoked and waited for before returning, ie. no background revalidation.
Default: false
(no retries)
-
retry: true
will infinitely retry failing tasks. -
retry: false
will disable retries. -
retry: 5
will retry failing tasks 5 times before bubbling up the final error thrown by task function. -
retry: (failureCount: number, error: unknown) => ...
allows for custom logic based on why the task failed.
Default: (invocationCount: number) => Math.min(1000 * 2 ** invocationCount, 30000)
The default configuration is set to double (starting at 1000ms) for each invocation, but not exceed 30 seconds.
This setting has no effect if retry
is false
.
-
retryDelay: 1000
will always wait 1000 milliseconds before retrying the task -
retryDelay: (invocationCount) => 1000 * 2 ** invocationCount
will infinitely double the retry delay time until the max number of retries is reached.
If your storage mechanism can't directly persist the value returned from your task function, supply a serialize
method that will be invoked with the result from the task function and this will be persisted to your storage.
A good example is if your task function returns an object, but you are using a storage mechanism like window.localStorage
that is string-based. For that, you can set serialize
to JSON.stringify
and the object will be stringified before it is persisted.
This property can optionally be provided if you want to deserialize a previously cached value before it is returned.
To continue with the object value in window.localStorage
example, you can set deserialize
to JSON.parse
and the serialized object will be parsed as a plain JavaScript object.
There is a convenience static method made available if you need to manually write to the underlying storage. This method is better than directly writing to the storage because it will ensure the necessary entries are made for timestamp invalidation.
const cacheKey = 'your-cache-key'
const cacheValue = { something: 'useful' }
const result = await swr.persist(cacheKey, cacheValue)
The value will be passed through the serialize
method you optionally provided when you instantiated the swr
helper.
There is a convenience static method made available if you need to simply read from the underlying storage without triggering revalidation. Sometimes you just want to know if there is a value in the cache for a given key.
const cacheKey = 'your-cache-key'
const resultPayload = await swr.retrieve(cacheKey)
The cached value will be passed through the deserialize
method you optionally provided when you instantiated the swr
helper.
There is a convenience static method made available if you need to manually delete a cache entry from the underlying storage.
const cacheKey = 'your-cache-key'
await swr.delete(cacheKey)
The method returns a Promise that resolves or rejects depending on whether the delete was successful or not.
The cache helper method returned from the createStaleWhileRevalidateCache
function is a fully functional event emitter that is an instance of the excellent Emittery package. Please look at the linked package's documentation to see all the available methods.
The following events will be emitted when appropriate during the lifetime of the cache (all events will always include the cacheKey
in its payload along with other event-specific properties):
Emitted when the cache helper is invoked with the cache key and function as payload.
Emitted when a fresh or stale value is found in the cache. It will not emit for expired cache values. When this event is emitted, this is the value that the helper will return, regardless of whether it will be revalidated or not.
Emitted when a value was found in the cache, but it has expired. The payload will include the old cachedValue
for your own reference. This cached value will not be used, but the task function will be invoked and waited for to provide the response.
Emitted when a value was found in the cache, but it is older than the allowed minTimeToStale
and it has NOT expired. The payload will include the stale cachedValue
and cachedAge
for your own reference.
Emitted when no value is found in the cache for the given key OR the cache has expired. This event can be used to capture the total number of cache misses. When this happens, the returned value is what is returned from your given task function.
Emitted when an error occurs while trying to retrieve a value from the given storage
, ie. if storage.getItem()
throws.
Emitted when an error occurs while trying to persist a value to the given storage
, ie. if storage.setItem()
throws. Cache persistence happens asynchronously, so you can't expect this error to bubble up to the main revalidate function. If you want to be aware of this error, you have to subscribe to this event.
Emitted when a duplicate function invocation occurs, ie. a new request is made while a previous one is not settled yet.
Emitted when an in-flight request is settled (resolved or rejected). This event is emitted at the end of either a cache lookup or a revalidation request.
Emitted whenever the task function is invoked. It will always be invoked except when the cache is considered fresh, NOT stale or expired.
Emitted whenever the revalidate function failed, whether that is synchronously when the cache is bypassed or asynchronously.
A slightly more practical example.
import {
createStaleWhileRevalidateCache,
EmitterEvents,
} from 'stale-while-revalidate-cache'
import { metrics } from './utils/some-metrics-util.ts'
const swr = createStaleWhileRevalidateCache({
storage: window.localStorage, // can be any object with getItem and setItem methods
minTimeToStale: 5000, // 5 seconds
maxTimeToLive: 600000, // 10 minutes
serialize: JSON.stringify, // serialize product object to string
deserialize: JSON.parse, // deserialize cached product string to object
})
swr.onAny((event, payload) => {
switch (event) {
case EmitterEvents.invoke:
metrics.countInvocations(payload.cacheKey)
break
case EmitterEvents.cacheHit:
metrics.countCacheHit(payload.cacheKey, payload.cachedValue)
break
case EmitterEvents.cacheMiss:
metrics.countCacheMisses(payload.cacheKey)
break
case EmitterEvents.cacheExpired:
metrics.countCacheExpirations(payload)
break
case EmitterEvents.cacheGetFailed:
case EmitterEvents.cacheSetFailed:
metrics.countCacheErrors(payload)
break
case EmitterEvents.revalidateFailed:
metrics.countRevalidationFailures(payload)
break
case EmitterEvents.revalidate:
default:
break
}
})
interface Product {
id: string
name: string
description: string
price: number
}
async function fetchProductDetails(productId: string): Promise<Product> {
const response = await fetch(`/api/products/${productId}`)
const product = (await response.json()) as Product
return product
}
const productId = 'product-123456'
const result = await swr<Product>(productId, async () =>
fetchProductDetails(productId)
)
const product = result.value
// The returned `product` will be typed as `Product`
The main breaking change between v2 and v3 is that for v3, the swr
function now returns a payload object with a value
property whereas v2 returned this "value" property directly.
For v2
const value = await swr('cacheKey', async () => 'cacheValue')
For v3
Notice the destructured object with the
value
property. The payload includes more properties you might be interested, like the cachestatus
.
const { value, status } = await swr('cacheKey', async () => 'cacheValue')
For all events, like the EmitterEvents.cacheExpired
event, the cachedTime
property was renamed to cachedAt
.
The swr.persist()
method now throws an error if something goes wrong while writing to storage. Previously, this method only emitted the EmitterEvents.cacheSetFailed
event and silently swallowed the error.
This was only a breaking change since support for Node.js v12 was dropped. If you are using a version newer than v12, this should be non-breaking for you.
Otherwise, you will need to upgrade to a newer Node.js version to use v2.
MIT License