Skip to main content

Search

Search problems, hooks, and pages

Back to About

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

A cache miss that fails to fetch throws rather than returning empty — the caller is expected to catch it and offer a retry or an open-in-new-tab fallback, instead of rendering a silently blank reader.

Where it lives in the repo

  • src/utils/pdf-cache.ts
  • src/data/pdf-guides.ts
  • src/app/serwist/[path]/route.ts