Case Study

GlyphCache

IndexedDB cache Offline fonts Network fallback

Most projects treat web fonts as a nice extra: you pick a typeface, wire up some @font-face rules, and live with a moment where everything appears in a fallback font before snapping into place. On fast connections that’s tolerable. On slow or flaky ones, it’s distracting, and sometimes the “nice extra” never shows up at all.

Browsers already have HTTP caching and font-display, but they still think in terms of individual responses. I wanted something more direct: once a user has downloaded a font, treat it as a local asset I can reach for quickly, even if the network is slow or offline.

glyphcache.js is a small browser utility for that idea. It stores font binaries in a persistent cache and injects @font-face rules that point at local Blob URLs generated from that stored data. Once the cache is warm, the browser can render with those fonts without touching the network again.


Why I Built It This Way

I had a few constraints in mind from the start:

  • keep the API boring enough to read at a glance,
  • keep it browser‑only (no build system or framework required),
  • and fail in a way that quietly falls back to normal font loading.

Conceptually, the flow looks like this:

Font Definitions → Cache Layer → CSS Injection → Rendering

That separation helped me keep a clear mental model: one part knows about data storage, one part knows about turning bytes into CSS, and the rest of the app just keeps using its normal font-family values.


Storing Fonts Where They Run

HTTP caches are powerful, but opaque: headers, network conditions, and user settings all influence whether a response is reused. Service workers add control but also more moving pieces and lifetime rules to think about.

For glyphcache, I wanted a place where fonts felt more like “local assets.” IndexedDB is built for binary data and survives across sessions, which fit nicely. Its API is not exactly ergonomic, so I wrapped it in small helpers for opening a database, reading by key, and writing new values.

A simplified version of the pattern looks like this:

type DbOptions = { name: string; version: number };

function openStore({ name, version }: DbOptions): Promise<IDBDatabase> {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open(name, version);
    req.onerror = () => reject(req.error);
    req.onsuccess = () => resolve(req.result);
    req.onupgradeneeded = () => {
      if (!req.result.objectStoreNames.contains("fonts")) {
        req.result.createObjectStore("fonts");
      }
    };
  });
}

In the real utility, the font file URL effectively becomes the key. Whatever versioning scheme a project already uses—hashed filenames, query params, or both—naturally feeds into cache invalidation: change the URL, and it becomes a new entry; keep it the same, and the cache reuses what’s already stored.


From Bytes to @font-face

Once there’s a place to store things, the rest of the flow is:

  • for each font source, try to read the bytes from the cache;
  • if they’re missing, fetch from the network and persist them;
  • wrap the bytes in a Blob, create a Blob URL; and
  • generate a @font-face rule that mirrors what you would have written by hand.

I kept that logic as a small pipeline instead of burying it in call sites. Conceptually:

async function ensureFontsCached(fonts: FontDefinition[]): Promise<void> {
  const db = await openStore({ name: "fonts-db", version: 1 });
  // load, fetch if needed, and inject CSS for each font
}

The important part isn’t the exact implementation; it’s that the caller sees a single, clear operation that says “warm up fonts,” while the complexity of caching and CSS injection stays in one place.

From the browser’s point of view, once the @font-face block is injected, it’s just CSS. It doesn’t care whether the bytes came from the network or from the cache. Design tokens, CSS variables, and components keep using the same font-family names they already know.

If anything fails—IndexedDB is unavailable, a fetch errors, a write throws—the utility calls a provided error handler and quietly steps aside. In that case, the browser simply falls back to its normal behavior and, in the worst case, to system fonts.


Staying Deliberately Small

glyphcache is intentionally scoped to the browser environment. It assumes access to document APIs and doesn’t try to be clever about Node, SSR, or asset pipelines. Those concerns stay in the build and deployment setup where they belong.

I also avoided aggressive lifetime management for Blob URLs. Most apps use a small, stable set of fonts, so keeping a handful of Blob URLs alive for the life of the page is a reasonable trade‑off for simpler behavior. If you’re at the point where font Blob lifecycle tuning is your main problem, things are going impressively well.

The public surface area is small on purpose. You define a list of fonts that matches your design system, call the utility once on startup, and then forget about it. New visitors load fonts and warm the cache; returning visitors and offline sessions benefit from what’s already stored.


What I Learned

Working on glyphcache reinforced a few ideas:

  • Treating fonts as data, not just responses, makes caching more predictable.
  • A focused, single‑responsibility utility can clean up a surprisingly large UX rough edge.
  • “Doesn’t get in the way when it fails” is a design constraint worth writing down explicitly.

You could reproduce this behavior by hand with a network tab, a Unicode‑length patience threshold, and some IndexedDB calls in the console. glyphcache just packages that effort into one quiet, boring operation so good typography is a little less dependent on a perfect connection.