# cachified and the small API that makes caching boring

> @epic-web/cachified wraps any key value store that can set, get and delete in a single function call that adds time to live, stale-while-revalidate, value validation and type safety. The design is deliberately thin, the storage is pluggable, and the parts that usually go wrong, background revalidation in a serverless runtime and a stale value after a failed refresh, are the parts the options expose directly.

**epicweb-dev/cachified** — 🤑 wrap virtually everything that can store by key to act as cache with ttl/max-age, stale-while-validate, parallel fetch protection and type-safety support

- Repository: https://github.com/epicweb-dev/cachified
- Stars: 1,061 · Forks: 29
- Language: TypeScript
- License: MIT
- Published: 2026-10-19 · Updated: 2026-10-19 · Language: en
- Canonical page: https://hysenlabs.com/projects/epicweb-dev-cachified

## A cache is a key, a store and a fetcher

The pitch for @epic-web/cachified is that it makes your app faster by caching values with support for time to live, stale-while-revalidate, cache value validation, batching and type safety. The library comes from the Epic Web project, and there is a conference talk about it called Caching for Cash.

The API is one function, cachified, which takes an options object and returns a value. The options carry three required things. key is the string the value is cached by and must be unique for each value. cache is the cache implementation to use. getFreshValue is the function called when no valid value is in the cache for the given key, which is basically what you would do if you were not using a cache, and which can be async and must return a fresh value or throw.

The elegance is that everything else is optional. ttl is the amount of milliseconds the value should stay in the cache before a fresh one is fetched, and it defaults to Infinity, which means a value never goes stale on its own. Setting any negative value disables caching altogether, which is a useful switch for turning a cache off in one environment without changing code.

What you get back is the value. The README walks through the same call three times to make the behaviour concrete: on the first call the cache was empty so getFreshValue was invoked and the result was cached for five minutes; two minutes later the same data was returned and getFreshValue was not invoked; ten minutes later the cached value had timed out, getFreshValue ran again and the new copy replaced the old one.

## The store is three methods, so almost anything qualifies

The cache implementation must conform to a small signature, and that is the whole contract:

```ts
set(key: string, value: object): void | Promise<void>
get(key: string): object | Promise<object>
delete(key: string): void | Promise<void>
```

Three methods, and each may return a promise. That constraint is small enough that an in-memory map, a Redis client, a database table or an existing cache library all qualify, which is the point of the design. The package does not ship a cache, it ships the policy around one.

The example in the README wires up lru-cache, and the comment is explicit that the LRU cache is not part of this package but a simple non-persistent cache. The interesting part is the set method, which is where the library's own semantics leak into the store:

```ts
set(key, value) {
  const ttl = totalTtl(value?.metadata);
  return lruInstance.set(key, value, {
    ttl: ttl === Infinity ? undefined : ttl,
    start: value?.metadata?.createdTime,
  });
}
```

Two things happen here. The totalTtl helper computes the remaining lifetime of a value from its metadata, and the Infinity case is translated into undefined so the store keeps the entry indefinitely. The start option passes the value's creation time to the store, which is how a store with its own expiry model can be kept in step with the entry's real age.

For the common distributed case there are official adapters, and the reason for them is stated in the README: using the adapters makes sure the used caches clean up outdated values themselves. Adapters exist for redis and for redis-json.

The design decision here is that cachified owns the policy and you own the storage, which is the opposite of a caching framework that brings its own store and its own eviction rules.

## Stale-while-revalidate, and the serverless problem it creates

staleWhileRevalidate is the amount of milliseconds that a value with an exceeded ttl is still returned while a fresh value is refreshed in the background. It should be positive, it can be infinite, and it defaults to 0, which means no stale serving at all. There is also swr as an alias for the same option.

This is the feature that turns a cache from a latency optimisation into a load shedding mechanism. With a stale window, the user gets an answer immediately and the origin sees one refresh instead of a thundering herd, at the cost of occasionally serving data that is a few seconds old. With a large enough window, the origin is protected almost entirely from read traffic spikes.

The awkward part of that feature is where the refresh happens. A background refresh is work that continues after the response is returned, and in a serverless runtime the function can be frozen or shut down the moment the response is sent. The library solves this with the waitUntil option, a function that takes a promise and represents background tasks which must be completed before the server can shut down, and the README names Cloudflare Workers as the environment where this is useful.

If you deploy to a serverless platform and do not pass waitUntil, the background revalidation can be killed with the invocation, which means stale values get served and never refreshed, and the cache slowly fills with old data. That is the kind of failure that looks like a caching bug and is actually a platform integration detail.

The traceId option exists for the same reason. It is a trace id for debugging, stored along with the cache metadata and accessible in getFreshValue and in the reporter, so a slow or failed refresh can be correlated with the request that caused it.

## checkValue is where type safety actually comes from

The type safety story in cachified is not about generics, it is about validation at read time. The checkValue option is a validator that checks every cached and fresh value to ensure type safety, and it can be a standard schema validator, or a custom validator function, or a standard schema, or a schema.

The rules for what counts as good are spelled out in the option's own documentation. A value is considered ok when the schema succeeds, or when the validator returns true, or when the validator returns undefined, or when the validator returns null. A value is considered bad when the schema throws, or when the validator returns false, or when the validator returns a reason as a string, or when the validator throws.

A validator function receives two arguments, the value and a migrate callback, and that migrate callback is the part that makes cached data survive a schema change. When the shape of what you cache changes between deploys, the validator can migrate the old entry forward instead of throwing it away, which is the difference between a cache that heals and a cache that resets on every deploy.

The default is undefined, meaning no validation. That is worth stating plainly, because it means the type safety in the package name is opt in. A cache with a validator will reject an entry written by an older version of your code, and a cache without one will hand your application a value of a shape it cannot handle, from a store that might be shared with other applications or other versions.

The standard schema reference in the option documentation matters for the ecosystem question. Because the option accepts a standard schema, the same validator can come from a schema library you already use, rather than requiring a cachified specific adapter.

## forceFresh, fallbackToCache and the metadata contract

Two options handle the cases where the normal cache contract is not enough.

forceFresh, when set to true, does not even try reading the currently cached value, and it writes the new value to the cache even when the cached value is still valid. The default is false. This is the escape hatch for a revalidation button, a post-mutation read, or a background job that needs a guaranteed fresh copy without throwing away the rest of the cache.

fallbackToCache controls what happens when getting a forced fresh value fails. It is a boolean, and it can also be a positive number, which is then the maximum age in milliseconds that a fallback value might have. The default is Infinity, so by default a failed refresh still returns whatever is in the cache, however old.

That default is the behaviour most systems want, and it is also the one that hides failures. If the upstream is down, cachified keeps serving old data indefinitely, and the only symptom is a log line. Setting fallbackToCache to a number, or to false, turns a silent degradation into a visible error, and the README's own description of the option makes it clear that the boolean form is the one that changes the failure mode.

The metadata contract ties the options together. The value passed to getFreshValue carries context metadata with ttl, swr and createdTime, and a background flag that tells the fetcher whether it is being called for a background revalidation or for a foreground request. The value stored in the cache carries its own metadata, since totalTtl reads value?.metadata and start comes from value?.metadata?.createdTime. That is how a single library can serve a store with its own expiry semantics without the store needing to understand the policy.

## esbuild for the bundle, tsc for the types, jest for the tests

The package manifest shows a small and conventional toolchain. The build script runs three steps: build declarations with tsc, then build the ES module with esbuild, then the CommonJS build. The esbuild invocations bundle src/index.ts into dist/index.mjs and dist/index.cjs with the esm and cjs formats, targeting es2020 and emitting source maps.

The exports map points the package entry at those two files, with types from dist/src/index.d.ts, and main and module fields are set to match. The prepare script removes the dist directory and runs the build, so a fresh install from a git checkout produces a working package.

The declaration build cleans up after itself, removing the spec and testHelpers declaration files from dist/src, which is the kind of detail that separates a package meant for public consumption from one that is only ever consumed from source.

The tests run under jest, with ts-jest in the dev dependencies and a jest.config.js at the root. The dev dependency list also includes zod-legacy, aliased to zod@3.23.5, which is a legacy zod 3 release kept in the dev dependencies.

The repository itself is small, with src/, the config files, a.releaserc.yml for semantic release, an.nvmrc for the Node version and a.prettierrc. The published version in package.json is 0.0.0-development, which is the placeholder semantic release overwrites, and it is the reason the tag numbers and the manifest version never match.

## MIT, semantic release, and a package version that reads zero

The licence is MIT, and the package metadata agrees. For a library that sits between your application and your data store, MIT is a permissive choice, though it still requires keeping the copyright and licence notice.

The author is Hannes Diercks, and the project sits under the Epic Web organisation, which also means the library inherits that community's conventions: a code of conduct badge, a build badge, a talk, and a companion site for the material behind the API. The README documentation is unusually complete on the options object, with every option documented inline with its type, its default and the conditions under which a value is accepted or rejected.

On maintenance, the repository is not archived and the last push was on 2026-08-01. The releases are v5.6.3 on 2026-07-02, v5.6.2 on 2026-03-17 and v5.6.1 on 2026-01-26, so a major version five with patch releases spread over months rather than a stream of minor bumps, which is what you would expect from a library whose API surface has stabilised.

One caveat for anyone pinning versions: the version field in package.json reads 0.0.0-development because the.releaserc.yml configuration drives semantic release, which writes the real version into the published tarball and creates the tags. That is a good workflow, but it means the repository's manifest tells you nothing about which version you are looking at, and the changelog lives in the release notes rather than in a file in the tree.

## Conclusion

Adopt @epic-web/cachified if you already have a key value store, a database or an in-memory map, and you want time to live, stale-while-revalidate and runtime type checking around it without adopting a caching framework. Do not adopt it if you need distributed cache coherence, since the library holds no state of its own and inherits whatever consistency your store provides. Verify three things first: that your store satisfies the three method Cache interface, that your serverless runtime gives you a waitUntil hook so background revalidation is not cut short, and that your cached values have a schema you can validate against, because the default checkValue is undefined and a cache without validation returns whatever was stored. The last push was on 2026-08-01 and the newest release is v5.6.3 from 2026-07-02, published by semantic release.

## FAQ

### What does @epic-web/cachified do?

It wraps a key value store in a single function that adds a time to live, stale-while-revalidate, runtime value validation and type safety. The value is cached by a key you supply, and getFreshValue is called when no valid entry exists.

### Which cache stores work with cachified?

Any object with set, get and delete methods, each of which may return a promise. The README shows lru-cache, and official adapters exist for redis and redis-json so that those stores clean up outdated values themselves.

### How does stale-while-revalidate work in cachified?

staleWhileRevalidate, with swr as an alias, is the number of milliseconds a value with an exceeded ttl is still returned while a fresh value is fetched in the background. It defaults to 0, and in serverless environments you should pass waitUntil so the background work is not cut short.

### How do I validate values stored in the cache?

Pass checkValue, which can be a standard schema validator, a standard schema or a custom validator function receiving the value and a migrate callback. A validator that returns false, throws or returns a reason string marks the value bad, and the default is undefined, meaning no validation at all.

### What happens if a fresh fetch fails in cachified?

The fallbackToCache option decides. It can be true, false, or a positive number of milliseconds, which is the maximum age a fallback value may have. The default is Infinity, so the cache is used however old the value is.

### What licence is @epic-web/cachified released under?

MIT. The newest release is v5.6.3 from 2026-07-02, the last push was on 2026-08-01, and the version field in package.json reads 0.0.0-development because releases are produced by semantic release.

## Sources

- [epicweb-dev/cachified on GitHub](https://github.com/epicweb-dev/cachified)
- [Issues](https://github.com/epicweb-dev/cachified/issues)
- [License: MIT](https://github.com/epicweb-dev/cachified/blob/main/LICENSE)
- [README](https://github.com/epicweb-dev/cachified/blob/main/README.md)
- [Releases](https://github.com/epicweb-dev/cachified/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/epicweb-dev-cachified
