memoize-swr
v1.0.1
Published
Memoize function results with LRU, TTL, stale-while-revalidate, stale-if-error, and Promise-aware concurrency.
Maintainers
Readme
memoize-swr
Memoize that speaks HTTP. Stale-while-revalidate. Stale-if-error. LRU. Promises that don't stampede. 1.2 kB gzipped. Zero dependencies.
lodash.memoize is a toy. mem forgot Promises exist. memoizee is still writing 2014 callback fanfic. This is the one with a real cache.
memoize and memoizee were taken. npm wouldn't even let us have the extra e. So we named it after the feature they don't have.
import { memoize } from "memoize-swr"
const loadUser = memoize(async (id: string) => {
const res = await fetch(`/users/${id}`)
return res.json()
}, { maxAge: 60_000, maxSize: 100 })
await loadUser("42")
await loadUser("42") // 💥 cache hit — fetch does not run again⚡ Node, Bun, browsers, Cloudflare Workers. Not a Node plugin with a browser afterthought. 1.2 kB gzipped — that's the whole library, not the landing page.
✨ Why this, not the other one
- 🏎️ In-flight coalescing by default — 40 parallel
fetchUser(1)calls = one request, not a thundering herd - 🧠 Real cache, not a Map — stale-while-revalidate and stale-if-error, the same model CDNs use
- 🧹 LRU that actually LRU's — hits bump; overflow drops the cold ones. Not "hope you called
.clear()" - 🎁
ship+dispose— unwrap, validate, or kill a browser/pool/handle when the entry dies. The other libs just leak it - 🧊 Sync when you want it —
{ async: false }. Promise-first without makingfib(40)return a Promise - 🪶 1.2 kB gzipped, zero runtime deps —
memoizeedragged half ofes5-extinto your bundle. We shipped the cache instead.
vs the usual suspects
| | memoize-swr | memoizee | p-memoize | mem / lodash.memoize |
| --- | :---: | :---: | :---: | :---: |
| Promise coalescing | ✅ default | 🫠 optional ritual | ✅ | ❌ stampede city |
| stale-while-revalidate | ✅ | ❌ (sort of preFetch) | ❌ | ❌ |
| stale-if-error | ✅ | ❌ | ❌ | ❌ |
| LRU + TTL together | ✅ | ✅ | 🤏 | 🤏 |
| dispose / cleanup | ✅ | ✅ | ❌ | ❌ |
| ship on the way out | ✅ | ❌ | ❌ | ❌ |
| TypeScript as a first-class citizen | ✅ | bolted on | ✅ | ✅ |
| Runtime dependencies | 0 | a small village | ~1 | 0 |
| Written this decade | ✅ | 🗿 | ✅ | ✅ |
If your current memoize can't serve stale data while it refreshes, can't survive an upstream 500, and can't close the Puppeteer browser it cached — that's not a cache. That's a Map with a marketing site.
🔥 Features
Everything a cache actually needs. Nothing a 2014 es5-ext jungle needs.
- ⚡ Promise-first — always returns a Promise unless you opt into
{ async: false } - 🧲 In-flight coalescing — parallel calls with the same key share one worker, not forty
- ⏳ TTL (
maxAge) — entries go stale on a clock, not on vibes - ♻️ Stale-while-revalidate — serve stale instantly, refresh in the background
- 🛟 Stale-if-error — upstream exploded? keep serving what you have
- 📚 LRU (
maxSize) — hits go to the front; overflow evicts the cold ones - 🔑 Custom
keyer— cache onuser.id, notJSON.stringifyof the whole object - 📦
ship— unwrap or validate on the way out; throw = cache miss - 🗑️
dispose+waitForDispose— close the browser, drain the pool, then compute the replacement - 🧊 Sync mode —
fib(40)returns a number. Imagine that. - 🎛️
.clear().delete().evict().stats()— you own the cache, it doesn't own you - 🕐 Custom clock —
fn.call({ now }, ...args)so tests don't wait for real time - 🌍 Runs everywhere — Node, Bun, browsers, Cloudflare Workers
- 🪶 1.2 kB gzipped, 0 deps, tree-shakable ESM
📦 Install
npm install memoize-swrimport { memoize } from "memoize-swr"const { memoize } = require("memoize-swr")⚙️ Behavior
By default the wrapped function always returns a Promise, even if the original function is synchronous. Parallel calls with the same cache key share one in-flight result.
The default cache key is JSON.stringify(args).
Pass { async: false } when you need a synchronous return value.
🎛️ Options
| Option | Default | Description |
| --- | --- | --- |
| async | true | When true, always return a Promise. When false, return the work function's value as-is. |
| maxSize | — | Maximum number of entries. Excess entries are dropped LRU (a hit moves an entry to most-recently used). |
| maxAge | — | Time-to-live in milliseconds. After this, the entry is stale. |
| staleWhileRevalidate | — | Extra milliseconds after maxAge during which the stale value is returned immediately while a refresh runs in the background. Requires maxAge. |
| staleIfError | — | Extra milliseconds after maxAge during which the stale value is returned if a refresh throws. Requires maxAge. |
| keyer | JSON.stringify | (...args) => string used as the cache key. |
| ship | identity | Maps a cached value to what the caller receives. If it throws, that lookup is treated as a miss. |
| dispose | — | Called when an entry is removed. May return a Promise. |
| waitForDispose | false | Wait for dispose to finish before computing a replacement. Cannot be combined with the stale options. |
| noConcurrents | false | Do not coalesce parallel calls for the same key. |
Entries are kept until maxAge + max(staleWhileRevalidate, staleIfError) so a stale hit can still find them.
🧰 Cache methods
The wrapped function has these extra methods:
fn.clear() // drop every entry (awaits dispose)
fn.delete(...args) // drop the entry for these arguments
fn.evict() // drop expired / over-capacity entries
fn.evict(Date.now()) // same, with an explicit clock
fn.stats() // { size: number }🧪 Examples
TTL and LRU
const fetchJson = memoize(async (url: string) => {
const res = await fetch(url)
return res.json()
}, {
maxAge: 60_000,
maxSize: 50,
})Stale-while-revalidate and stale-if-error
Serve cached data past its TTL, refresh in the background, and fall back to stale data if the refresh fails:
const fetchJson = memoize(async (url: string) => {
const res = await fetch(url)
if (!res.ok) throw new Error(String(res.status))
return res.json()
}, {
maxAge: 60_000,
staleWhileRevalidate: 24 * 60 * 60 * 1000,
staleIfError: 24 * 60 * 60 * 1000,
})Custom cache keys
const loadById = memoize(
async (user: { id: string }) => db.users.find(user.id),
{ keyer: (user) => user.id },
)ship: validate or unwrap on the way out
ship runs on cache hits. Throwing treats the entry as missing and recomputes:
const authorize = memoize(async (email: string, key: string) => {
const creds = await issueToken(email, key)
return { value: creds, expiresAt: creds.expiry_date }
}, {
ship: (data) => {
if (data.expiresAt && data.expiresAt < Date.now()) {
throw new Error("Credentials expired")
}
return data.value
},
})dispose: clean up cached resources
const getBrowser = memoize(launchBrowser, {
maxAge: 5 * 60 * 1000,
maxSize: 1,
dispose: (browser) => browser.close(),
waitForDispose: true,
})Synchronous memoization
const fib = memoize((n: number): number => {
if (n < 2) return n
return fib(n - 1) + fib(n - 2)
}, { async: false })
fib(40) // number, not a PromiseCustom clock
Pass { now } as this to use a timestamp other than Date.now() — useful in tests:
await fn.call({ now: 1_000 }, "abc")
await fn.call({ now: 10_000 }, "abc")License
MIT
