A simple API to make your app faster.
Cachified allows you to cache values with support for time-to-live (ttl),
stale-while-revalidate (swr), cache value validation, batching, and
type-safety.
npm install @epic-web/cachified

Watch the talk "Caching for Cash 🤑"
on EpicWeb.dev:

Install
npm install @epic-web/cachified
Usage
import { LRUCache } from 'lru-cache';
import { cachified, CacheEntry, Cache, totalTtl } from '@epic-web/cachified';
const lruInstance = new LRUCache<string, CacheEntry>({ max: 1000 });
const lru: Cache = {
set(key, value) {
const ttl = totalTtl(value?.metadata);
return lruInstance.set(key, value, {
ttl: ttl === Infinity ? undefined : ttl,
start: value?.metadata?.createdTime,
});
},
get(key) {
return lruInstance.get(key);
},
delete(key) {
return lruInstance.delete(key);
},
};
function getUserById(userId: number) {
return cachified({
key: `user-${userId}`,
cache: lru,
async getFreshValue() {
const response = await fetch(
`https://jsonplaceholder.typicode.com/users/${userId}`,
);
return response.json();
},
ttl: 300_000,
});
}
console.log(await getUserById(1));
console.log(await getUserById(1));
console.log(await getUserById(1));
Options
interface CachifiedOptions<Value> {
key: string;
cache: Cache;
getFreshValue: GetFreshValue<Value>;
ttl?: number;
staleWhileRevalidate?: number;
swr?: number;
checkValue?:
| CheckValue<Value>
| StandardSchemaV1<unknown, Value>
| Schema<Value, unknown>;
forceFresh?: boolean;
fallbackToCache?: boolean | number;
staleRefreshTimeout?: number;
waitUntil?: (promise: Promise<unknown>) => void;
}
Adapters
There are some adapters available for common caches. Using them makes sure the used caches cleanup outdated values themselves.
Advanced Usage
Stale while revalidate
Specify a time window in which a cached value is returned even though
it's ttl is exceeded while the cache is updated in the background for the next
call.
import { cachified } from '@epic-web/cachified';
const cache = new Map();
function getUserById(userId: number) {
return cachified({
ttl: 120_000 ,
staleWhileRevalidate: 300_000 ,
cache,
key: `user-${userId}`,
async getFreshValue() {
const response = await fetch(
`https://jsonplaceholder.typicode.com/users/${userId}`,
);
return response.json();
},
});
}
console.log(await getUserById(1));
console.log(await getUserById(1));
console.log(await getUserById(1));
console.log(await getUserById(1));
Forcing fresh values and falling back to cache
We can use forceFresh
to get a fresh value regardless of the values ttl or stale while validate
import { cachified } from '@epic-web/cachified';
const cache = new Map();
function getUserById(userId: number, forceFresh?: boolean) {
return cachified({
forceFresh,
fallbackToCache: 300_000 ,
cache,
key: `user-${userId}`,
async getFreshValue() {
const response = await fetch(
`https://jsonplaceholder.typicode.com/users/${userId}`,
);
return response.json();
},
});
}
console.log(await getUserById(1));
console.log(await getUserById(1, true));
Type-safety
In practice we can not be entirely sure that values from cache are of the types we assume.
For example other parties could also write to the cache or code is changed while cache
stays the same.
import { cachified, createCacheEntry } from '@epic-web/cachified';
const cache = new Map();
cache.set('user-1', createCacheEntry('INVALID') as any);
function getUserById(userId: number) {
return cachified({
checkValue(value: unknown) {
if (!isRecord(value)) {
throw new Error(`Expected user to be object, got ${typeof value}`);
}
if (typeof value.email !== 'string') {
return `Expected user-${userId} to have an email`;
}
if (typeof value.username !== 'string') {
return false;
}
},
cache,
key: `user-${userId}`,
async getFreshValue() {
const response = await fetch(
`https://jsonplaceholder.typicode.com/users/${userId}`,
);
return response.json();
},
});
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
console.log(await getUserById(1));
console.log(await getUserById(1));
ℹ️ checkValue
is also invoked with the return value of getFreshValue
We can also use zod, valibot or other libraries implementing the standard schema spec to ensure correct types
import { cachified, createCacheEntry } from '@epic-web/cachified';
import z from 'zod';
const cache = new Map();
cache.set('user-1', createCacheEntry('INVALID') as any);
function getUserById(userId: number) {
return cachified({
checkValue: z.object({
email: z.string(),
}),
cache,
key: `user-${userId}`,
async getFreshValue() {
const response = await fetch(
`https://jsonplaceholder.typicode.com/users/${userId}`,
);
return response.json();
},
});
}
console.log(await getUserById(1));
console.log(await getUserById(1));
Pre-configuring cachified
We can create versions of cachified with defaults so that we don't have to
specify the same options every time.
import { configure } from '@epic-web/cachified';
import { LRUCache } from 'lru-cache';
const lruCachified = configure({
cache: new LRUCache<string, CacheEntry>({ max: 1000 }),
});
const value = await lruCachified({
key: 'user-1',
getFreshValue: async () => 'ONE',
});
Manually working with the cache
During normal app lifecycle there usually is no need for this but for
maintenance and testing these helpers might come handy.
import {
createCacheEntry,
assertCacheEntry,
isExpired,
cachified,
} from '@epic-web/cachified';
const cache = new Map();
cache.set(
'user-1',
createCacheEntry(
'someone@example.org',
{ ttl: 300_000, swr: Infinity },
),
);
const value: string = await cachified({
cache,
key: 'user-1',
getFreshValue() {
throw new Error('This is not called since cache is set earlier');
},
});
console.log(value);
const entry: unknown = cache.get('user-1');
assertCacheEntry(entry);
console.log(entry.value);
const expired = isExpired(entry.metadata);
console.log(expired);
cache.delete('user-1');
Migrating Values
When the format of cached values is changed during the apps lifetime they can
be migrated on read like this:
import { cachified, createCacheEntry } from '@epic-web/cachified';
const cache = new Map();
cache.set('user-1', createCacheEntry('someone@example.org'));
function getUserById(userId: number) {
return cachified({
checkValue(value, migrate) {
if (typeof value === 'string') {
return migrate({ email: value });
}
},
key: 'user-1',
cache,
getFreshValue() {
throw new Error('This is never called');
},
});
}
console.log(await getUserById(1));
console.log(await getUserById(1));
Soft-purging entries
Soft-purging cached data has the benefit of not immediately putting pressure on the app
to update all cached values at once and instead allows to get them updated over time.
More details: Soft vs. hard purge
import { cachified, softPurge } from '@epic-web/cachified';
const cache = new Map();
function getUserById(userId: number) {
return cachified({
cache,
key: `user-${userId}`,
ttl: 300_000,
async getFreshValue() {
const response = await fetch(
`https://jsonplaceholder.typicode.com/users/${userId}`,
);
return response.json();
},
});
}
console.log(await getUserById(1));
await softPurge({
cache,
key: 'user-1',
});
console.log(await getUserById(1));
console.log(await getUserById(1));
await softPurge({
cache,
key: 'user-1',
staleWhileRevalidate: 60_000 ,
});
console.log(await getUserById(1));
ℹ️ In case we need to fully purge the value, we delete the key directly from our cache
Fine-tuning cache metadata based on fresh values
There are scenarios where we want to change the cache time based on the fresh
value (ref #25).
For example when an API might either provide our data or null
and in case we
get an empty result we want to retry the API much faster.
import { cachified } from '@epic-web/cachified';
const cache = new Map();
const value: null | string = await cachified({
ttl: 60_000 ,
async getFreshValue(context) {
const response = await fetch(
`https://jsonplaceholder.typicode.com/users/1`,
);
const data = await response.json();
if (data === null) {
context.metadata.ttl = -1;
}
return data;
},
cache,
key: 'user-1',
});
Batch requesting values
In case multiple values can be requested in a batch action, but it's not
clear which values are currently in cache we can use the createBatch
helper
import { cachified, createBatch } from '@epic-web/cachified';
const cache = new Map();
async function getFreshValues(idsThatAreNotInCache: number[]) {
const res = await fetch(
`https://example.org/api?ids=${idsThatAreNotInCache.join(',')}`,
);
const data = await res.json();
return data;
}
function getUsersWithId(ids: number[]) {
const batch = createBatch(getFreshValues);
return Promise.all(
ids.map((id) =>
cachified({
getFreshValue: batch.add(
id,
({ value, ...context }) => {},
),
cache,
key: `entry-${id}`,
ttl: 60_000,
}),
),
);
}
console.log(await getUsersWithId([1, 2]));
console.log(await getUsersWithId([2, 3]));
Reporting
A reporter might be passed as second argument to cachified to log caching events, we ship a reporter
resembling the logging from Kents implementation
import { cachified, verboseReporter } from '@epic-web/cachified';
const cache = new Map();
await cachified(
{
cache,
key: 'user-1',
async getFreshValue() {
const response = await fetch(
`https://jsonplaceholder.typicode.com/users/1`,
);
return response.json();
},
},
verboseReporter(),
);
please refer to the implementation of verboseReporter
when you want to implement a custom reporter.
License
MIT