cache
An LRU/KV cache in front of head(), url(), and small download()s. Repeat reads of an unchanged key are served from memory; writes through the instance invalidate the affected key. Body-transparent, no native dependencies, works on any adapter.
The built-in cache() plugin puts an LRU (or your own KV) in front of the cheap read verbs. A repeat head() or url() - and, opt-in, a small download() - for an unchanged key is served from memory instead of round-tripping to the provider. Any write through the instance (upload, delete, copy, move) invalidates the affected key, so the next read re-fetches.
It writes no object metadata and has no native dependencies, so it works on any adapter. Like the other plugins it runs outside retries - a cache hit skips the retry loop entirely.
import { createFiles } from "files-sdk";
import { s3 } from "files-sdk/s3";
import { cache } from "files-sdk/cache";
const files = createFiles({
adapter: s3({ bucket: "uploads" }),
plugins: [cache()],
});
await files.head("a.png"); // miss → provider
await files.head("a.png"); // hit → memory
await files.upload("a.png", body); // invalidates "a.png"
await files.head("a.png"); // miss → provider again
What gets cached
By default cache() caches the two cheap, body-free verbs - head and url. Pass operations to change the set:
cache({ operations: ["head", "url", "download"] });
headcaches the metadata only. A hit returns aStoredFilewhose body still lazy-fetches on access - exactly the contract an uncachedheadhas - so nothing is buffered up front.urlcaches the returned string, keyed per url-options signature (so a plainurl()and aurl({ expiresIn })cache apart). Each entry is additionally capped at its ownexpiresIn, so a presigned URL is never handed out past its signature.downloadis off by default. With"download"enabled, only known-length bodies at or undermaxBytes(default 1 MiB) are buffered and cached; anything larger - or of unknown length - streams straight through uncached, so streaming and range downloads keep working. A cached small body is re-served as a fresh, re-readableStoredFile.
cache({
operations: ["head", "url", "download"],
maxBytes: 256 * 1024, // only cache downloads ≤ 256 KiB
});
Invalidation
Caching is only safe because writes evict. Every mutation through the instance drops the affected key’s entire record (all of its cached verbs) once the write lands:
| Write | Invalidates |
|---|---|
upload(key) |
key |
delete(key) |
key |
copy(from, to) |
to (destination) |
move(from, to) |
from and to |
Invalidation is keyed by the caller-facing key - never the internal prefixed path - so it lines up with the keys reads are cached under.
Writes the cache can’t see
A change the plugin never observes won’t invalidate: an upload made through a presigned URL, or a mutation straight against the provider. Treat the cache as eventually-consistent and evict by hand when that happens:
await files.invalidateCache("a.png"); // drop one key
await files.invalidateCache(); // drop everything
Stats
cacheStats() returns a fresh { hits, misses } snapshot for tuning your TTL and entry budget; resetCacheStats() starts a new window:
files.cacheStats(); // { hits: 41, misses: 9 }
files.resetCacheStats();
The store
By default the cache is a bounded in-memory LRU keyed by object key, holding maxEntries keys (default 1000) before evicting the least-recently-used. Each key’s record bundles every cached verb together, which is what makes invalidation a single delete.
Pass your own store to share the cache across instances or processes - e.g. a Redis-backed CacheStore that serializes each record:
const files = createFiles({
adapter: s3({ bucket: "uploads" }),
plugins: [cache({ store: myRedisStore })],
});
A CacheStore is four methods - get, set, delete, clear - each of which may be sync or async. A distributed store has an inherent read-modify-write race when two different verbs for the same key are first cached at the exact same instant; it’s harmless - it just costs a re-fetch next time.
TTL
Every entry honors a ttl (default 60_000 ms). Set 0 to disable time-based expiry entirely (entries then live until evicted or invalidated):
cache({ ttl: 30_000 });
For url, keep ttl comfortably below your signed-URL expiry. The per-entry expiresIn cap guarantees you never serve a dead URL, but a short ttl keeps the URLs you hand out fresh with plenty of life left.
Ordering
Place cache() first (outermost) so a hit short-circuits before the rest of the pipeline does any work:
plugins: [cache(), encryption(key)];
Put it after a body-transforming plugin only if you deliberately want to cache the transformed bytes (e.g. caching post-compression() output).
Things to keep in mind
- A cache is eventually-consistent. Out-of-band writes (presigned uploads, direct provider changes) won’t invalidate - call
invalidateCache(), and keep thettlhonest. downloadcaching buffers bodies. It’s gated to small, known-length objects for exactly this reason; large and unknown-length downloads always stream through untouched.- Bound your memory. With
downloadenabled, sizemaxEntriesandmaxBytestogether - the product is your ceiling. - It’s per-instance by default. The in-memory store lives with the
Filesinstance. Reach for a sharedstoreto cache across processes.