Cache-Once PDF Guides
built with: EmbedPDF (PDFium/WASM) + Cache Storage API + Vercel Blob
A fetch-exactly-once-per-device cache in front of Vercel Blob, wrapped around a WASM PDF renderer.
Cache-first, forever, on purpose
The PDFs live in Vercel Blob storage, where every download costs egress. So the cache policy is the strictest one available: fetch a given PDF once per device, ever, then read it from a dedicated `pdf-cache` bucket in Cache Storage from then on.
This is also why there is deliberately no "clear cache" button anywhere in the UI. A clear button is a button that re-triggers every download — precisely the thing the cache exists to prevent.
Storage is bounded a different way: `prunePdfCache` drops entries whose URLs are no longer in the current guide list, so retired PDFs do not accumulate on the device.
// src/utils/pdf-cache.ts
const cache = await caches.open(PDF_CACHE);
const hit = await cache.match(url);
if (hit) return hit.arrayBuffer();
const res = await fetch(url, { mode: 'cors' });
if (!res.ok) throw new Error(`PDF fetch failed: ${res.status}`);
// Persist a clone; a Response body can only be read once.
await cache.put(url, res.clone());
return res.arrayBuffer();Two details that bite
A `Response` body is a stream and can only be consumed once. Calling `cache.put(url, res)` and then `res.arrayBuffer()` throws — hence `res.clone()` before storing.
Opening the same guide twice in quick succession (double-click, or two tabs of the reader) would fire two identical network requests before either finished. An in-flight `Map` keyed by URL de-dupes them: the second caller awaits the same promise, and the entry is removed in a `finally` so a failed fetch does not poison later retries.
// src/utils/pdf-cache.ts
const inFlight = new Map<string, Promise<ArrayBuffer>>();
export async function ensurePdfBuffer(url: string): Promise<ArrayBuffer> {
const pending = inFlight.get(url);
if (pending) return pending;
const task = (async () => { /* ...cache lookup + fetch... */ })();
inFlight.set(url, task);
try {
return await task;
} finally {
inFlight.delete(url);
}
}Buffers, not URLs
The reader is EmbedPDF, which renders through a PDFium build compiled to WebAssembly. It is handed the document as in-memory bytes via `openDocumentBuffer` rather than a URL — which is exactly why the cache layer returns an `ArrayBuffer` instead of a blob URL.
The PDFium WASM binary itself is self-hosted from `public/pdfium.wasm` (copied in by a prebuild step) and precached by the service worker with a content hash, so the renderer works offline too and only re-downloads when the binary actually changes.
`navigator.storage.persist()` is requested best-effort so the cached PDFs are not the first thing evicted under storage pressure.
Important
Gotcha
Where it lives in the repo
- src/utils/pdf-cache.ts
- src/data/pdf-guides.ts
- src/app/serwist/[path]/route.ts