til · daniel metzner

Reading Open Library without hammering a nonprofit

Verso scans a book’s ISBN and shows you the title, author, cover and a bit of blurb. The obvious source is Google Books or an Amazon scrape — but both mean handing someone’s reading life to an ad company, which is exactly what the app is meant to avoid. So the metadata comes from Open Library, a nonprofit run by the Internet Archive.

Free and privacy-friendly comes with a responsibility: don’t hammer a charity’s servers. Here’s what I learned wiring it up.

The description lives on the work, not the edition

Open Library models books in two layers. An edition is one physical printing (one ISBN); a work is the abstract book that all its editions share. The blurb you want to show is on the work/works/{id}.json — and often isn’t on the edition record at all.

So a lookup is two hops: resolve the ISBN to a work key, then fetch the work for its description. And the description field is a shape-shifter — sometimes a plain string, sometimes an object:

// A description is a plain string OR { type, value } — both occur for real.
function textValue(d: unknown): string | null {
  if (typeof d === 'string') return d.trim() || null;
  if (d && typeof d === 'object' && 'value' in d) {
    const v = (d as { value?: unknown }).value;
    return typeof v === 'string' ? v.trim() || null : null;
  }
  return null;
}

If you assume it’s always a string, you get [object Object] in your UI the first time you hit the other shape. Both are valid; handle both.

search.json?isbn= resolves what /isbn/{isbn}.json can’t

The intuitive endpoint is /isbn/{isbn}.json. It works — until it 404s, which happens surprisingly often for German editions. What consistently does resolve those ISBNs is the search endpoint:

const search = await getJson(
  `https://openlibrary.org/search.json?isbn=${isbn}` +
  `&fields=key,first_publish_year,edition_count,number_of_pages_median,subject,publisher&limit=1`,
  signal
);
const doc = search?.docs?.[0];
const workKey = typeof doc?.key === 'string' ? doc.key : null; // → /works/OL...W

Two wins in one call. search.json?isbn= is the only endpoint that gives you first_publish_year and edition_count — nice “first published 1979, 42 editions” details — and its key is already the work key, so the second hop to /works/{key}.json for the description falls straight out. It also resolves a bunch of ISBNs that the direct edition endpoint refuses. When I need just the title/author/cover for the scan result I use the lighter /api/books?bibkeys=ISBN:…&jscmd=data endpoint; the richer detail view uses search.json.

One caution: search.publisher is an aggregate across every edition of the work, so it can be a huge list. Cap it (.slice(0, 3)) or you’ll render fifty publisher names.

Covers 404 — so cache the misses

Covers come from covers.openlibrary.org/b/isbn/{isbn}-M.jpg. Add ?default=false or a missing cover returns a blank placeholder image instead of a 404 — and you actually want the 404 so you know it’s missing. But then the browser will re-request that dead URL on every render and every filter switch. Rude to a nonprofit, and pointless. So I remember the misses in localStorage:

const MISS_KEY = 'curio.coverMiss';
const coverMiss = loadMiss(); // Set<string> from localStorage

export function markCoverMissing(isbn: string): void {
  const c = cleanIsbn(isbn);
  if (!c || coverMiss.has(c)) return;
  coverMiss.add(c);
  localStorage.setItem(MISS_KEY, JSON.stringify([...coverMiss]));
}

Once a cover is known missing, Cover.svelte skips the network entirely and goes straight to a styled placeholder.

Cache the details too — but only when the call completes

The detail lookup gets the same treatment: cache each ISBN’s result (including a definitive “not found,” stored as null) so expand/collapse or a revisit doesn’t re-hit the API. But there’s a subtle trap. If you cache on any exit, a timeout or an offline blip caches an empty answer — and now the book is permanently blurb-less even after you’re back online, because the cache says “already looked, nothing there.”

The fix is a completed flag. Only a resolved HTTP response (200 or 404 — either is a definitive answer) is cacheable. A network error or an aborted timeout leaves completed false, so nothing is written:

let result: BookDetails | null = null;
let completed = false;
try {
  const search = await getJson(url, ctrl.signal);
  completed = true; // resolved (200 or 404) → the answer is definitive
  // ...build result, optionally fetch the work for its description...
} catch {
  /* network error / timeout — leave completed=false so we don't cache it */
}

if (completed) {
  detailCache[isbn] = result;
  localStorage.setItem(DETAIL_KEY, JSON.stringify(detailCache));
}

The work fetch that adds the description is wrapped in its own try/catch inside the completed branch, because it’s optional enrichment — if it fails we still cache the good search-based fields rather than throwing the whole thing away.

The lesson

Being a good API neighbour isn’t only about rate limits — it’s about caching the right answers. Cache your 404s so you stop asking. Cache your successes so you don’t ask twice. But never cache a failure to reach the server, or you’ll turn one bad network moment into a permanently wrong result. “Did the call complete?” and “did it find anything?” are two different questions, and only the first one decides whether you’re allowed to remember the answer.

← all notes