cache
An LRU or KV cache in front of head(), url(), and small download()s - repeat reads of an unchanged key are served from memory, writes invalidate the key.
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. Aurl()withoutexpiresInis capped atdefaultUrlExpiresIn(default3600, the SDK-wide default) - the adapter signs such calls with its own default, which the plugin can’t see, so set this to match if you’ve configured a shorterdefaultUrlExpiresInon the adapter. When the URL states its own lifetime (X-Amz-Expireson S3 and S3-compatible adapters,X-Goog-Expireson GCS) and it’s shorter, that wins.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-readableStoredFilewith its own copy of the bytes and metadata, so a caller mutating one read can’t change the next.
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.
cache({
ttl: 30_000,
defaultUrlExpiresIn: 900, // the adapter signs url() calls without expiresIn for 15 minutes
});
TTL and expiry run off clock (default Date.now, in ms). Inject a fake for deterministic expiry in tests:
let now = 0;
const files = createFiles({ adapter, plugins: [cache({ clock: () => now })] });
now += 61_000; // every default-ttl entry is now stale
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).
The one plugin that belongs in front of it is signedUrlPolicy():
plugins: [signedUrlPolicy({ maxExpiresIn: 60 }), cache()];
Plugins run in array order, so a policy placed after cache() rewrites url() options the cache never sees. The cache would then key and cap each entry by the caller’s expiresIn rather than the shorter one the policy actually signed, and could serve a 60-second URL for up to an hour. Only URLs that state their own lifetime (S3-style and GCS) are caught either way.
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.