github.com/unkeyed/sdks/tree/main/cache
Motivation
Everyone needs caching, but it’s often poorly implemented. Not from a technical perspective but from a usability perspective. Caching should be easy to use, typesafe, and composable. How caching looks like in many applications:- Manual type casting
- No support for stale-while-revalidate
- Only checks a single cache
Features
- Typescript: Fully typesafe
- Tiered Cache: Multiple caches in series to fall back on
- Metrics: Middleware for collecting metrics
- Stale-While-Revalidate: Async loading of data from your origin
- Encryption: Middleware for automatic encryption of cache values
- Composable: Mix and match primitives to build what you need
Quickstart
Concepts
Namespaces
Namespaces are a way to define the type of data in your cache and apply settings to it. They are used to ensure that you don’t accidentally store the wrong type of data in a cache, which otherwise can happen easily when you’re changing your data structures. Each namespace requires a type parameter and is instantiated with a set of stores and cache settings.Constructor
The type of data stored in this namespace, for example:
An execution context, such as a request or a worker instance.Read moreOn Cloudflare workers or Vercel edge functions, you receive a context from the
fetch handler.Otherwise you can use this:Example namespace with two stores
Tiered Cache
Different caches have different characteristics, some may be fast but volatile, others may be slow but persistent. By using a tiered cache, you can combine the best of both worlds. In almost every case, you want to use a fast in-memory cache as the first tier. There is no reason not to use it, as it doesn’t add any latency to your application. The goal of this implementation is that it’s invisible to the user. Everything behaves like a single cache. You can add as many tiers as you want.Reading from the cache
When using a tiered cache, all stores will be checked in order until a value is found or all stores have been checked. If a value is found in a store, it will be backfilled to the previous stores in the list asynchronously.Writing to the cache
When setting or deleting a key, every store will be updated in parallel.Example
Stale-While-Revalidate
To make data fetching as easy as possible, the cache offers aswr method, that acts as a pull through cache. If the data is fresh, it will be returned from the cache, if it’s stale it will be returned from the cache and a background refresh will be triggered and if it’s not in the cache, the data will be synchronously fetched from the origin.
The cache key to fetch, just like when using
.get(key)A callback function that will be called to fetch the data from the origin if
it’s stale or not in the cache.
swr works with tiered caches, but for simplicity, these charts may only show a single store.
- Fresh Cache Hit
- Stale Cache Hit
- Cache Miss
Example
Context
In serverless functions it’s not always trivial to run some code after you have returned a response. This is where the context comes in. It allows you to register promises that should be awaited before the function is considered done. Fortunately many providers offer a way to do this. In order to be used in this cache library, the context must implement the following interface:DefaultStatefulContext:
Primitives
Stores
Stores are the underlying storage mechanisms for your cache. They can be in-memory, on-disk, or remote. You can use multiple stores in a namespace to create a tiered cache. The order of stores in a namespace is important. The cache will check the stores in order until it finds a value or all stores have been checked.You can create your own store by implementing the
Store interface. Read
more.Memory
The memory store is an in-memory cache that is fast but only as persistent as your memory. In serverless environments, this means that the cache is lost when the function is cold-started.Map is instantiated in a persistent scope of your application. For Cloudflare workers or serverless functions in general, this is the global scope.
Cloudflare
The Cloudflare store uses cloudflare’sCache API to store cache values. This is a remote cache that is shared across all instances of your worker but isolated per datacenter. It’s still pretty fast, but needs a network request to access the cache.
The Cloudflare API key to use for cache purge operations.The api key must have the
Cache Purge permission. You can create a new API token with this permission in the Cloudflare dashboard.The Cloudflare zone ID where the cache is stored. You can find this in the
Cloudflare dashboard.
The domain to use for the cache. This must be a valid domain within the zone specified by
zoneId.As your data changes, it is important to keep backwards compatibility in mind. If your cached values are no longer backwards compatible, it can cause problems. For example when a value changes from optional to required. In these cases you should purge the entire cache by setting a new
cacheBuster value. The cacheBuster is used as part of the cache key and changes ensure you are not reading old data anymore.Upstash Redis
The Upstash Redis store uses the Serverless Redis offering from Upstash to store cache values. This is a serverless database with Redis compatibility.The Upstash Redis client to use for cache operations.
libSQL (Turso)
The libSQL store can use an embedded SQLite database, or a remote Turso database to store cache values. You must create a table in your Turso database with the following schema:The libSQL client to use for cache
operations.
The name of the database table name to use for cache operations.
Middlewares
Metrics
The metrics middleware collects metrics about cache hits, misses, and backfills. It’s useful for debugging and monitoring your cache usage. Using the metrics middleware requires a metrics sink. You can build your own sink by implementing theMetrics interface.
For example we are using axiom.
Encryption
When dealing with sensitive data, you might want to encrypt your cache values at rest. You can encrypt a store by wrapping it with theEncryptedStore.
All you need is a 32 byte base64 encoded key.
You can generate one with openssl:
Generate a new encryption key
Example
AES-256-GCM and persisted in the underlying store.
You can rotate your encryption key at any point, but this will essentially purge the cache.A SHA256 hash of the encryption key is used in the cache key, to allow for rotation without causing decryption errors.