September 5, 2026·8 min read

A thumbnail for every site, without a person taking screenshots

When you run a network of sites, you want a little preview of each one — a visual index of the whole thing. Doing that by hand is a chore that's always out of date. Doing it automatically is a small pipeline with a surprising number of ways to go wrong.

By Andrew Pyle

Once you run more than a handful of sites, you want a way to see them all at a glance — a visual index, a grid of little previews, each one a thumbnail of a real site so the network is something you can look at rather than just a list of names. It's a small feature with an outsized effect: a wall of screenshots makes an abstract portfolio suddenly concrete and browsable.

The naive way to build it is to take the screenshots yourself, drop them in a folder, and reference them. That works for exactly as long as it takes the sites to change, at which point every thumbnail is quietly out of date and you're back to a chore you'll never keep up with. So the real version is a small automated pipeline — capture each site, store the image, serve it, and handle the inevitable case where one is missing. It sounds trivial. It has more failure points than you'd guess.

01Stale by hand

Hand-made previews are stale the day after

The problem with capturing thumbnails by hand is the same problem as any hand-maintained representation of a moving thing: it's accurate at the moment you make it and drifting immediately after. Sites get redesigned, content changes, new ones join the network. Every one of those changes makes a hand-captured thumbnail a little more wrong, and because updating them is tedious, they don't get updated — so the visual index slowly becomes a museum of what the sites used to look like. A preview that misrepresents the current site is worse than no preview, because it actively misleads.

So the requirement is that the thumbnails refresh themselves without me in the loop — a scheduled, automatic act that re-photographs each site on some cadence, so the index reflects reality within a bounded staleness instead of drifting forever. A network of sites has too many images for a human's memory to be the refresh mechanism.

Any hand-captured preview of a live thing is accurate once and wrong thereafter. If refreshing it depends on you remembering, it's already stale.

02Three jobs

Capture, store, serve — three separable jobs

The pipeline has three distinct stages, and keeping them separable is what keeps it sane. Capture: something visits each site like a browser would and takes a picture of it. Store: that image gets put somewhere durable and addressable. Serve: the page that displays the grid pulls each thumbnail from storage and renders it. Each stage is a different kind of thing — an automation, a store, a frontend — and problems tend to live cleanly in one of them.

In the actual build each stage is a specific, unglamorous thing. Capture is a headless Chromium browser that visits each site, waits for it to draw, and takes an above-the-fold shot encoded as a compact WebP. Store is an object store: each image lands at a key derived from the site's host. Serve is the /network grid, which ships as prerendered static HTML, so the previews are already in the page a crawler sees rather than painted in later by script.

# the thumbnail address is a deterministic function of the host —
# it exists whether or not a capture has ever run for that site
https://<object-store-host>/network_thumbs/<www-stripped-lowercase-host>.webp

The separation also means the stages run on their own schedules in their own systems. The capture writes to the store; the store just holds images; the page reads from the store whenever it renders. Nothing about displaying the grid triggers a capture, and nothing about capturing knows how the grid looks — each piece has one job and a clean handoff to the next.

03Missing is normal

The missing image is the normal case

The failure I underestimated is the most mundane one: sometimes the thumbnail just isn't there. The capture hasn't run yet, or it failed, or the image didn't make it into the store — and the page goes to display a thumbnail that doesn't exist. If you didn't plan for that, you get a broken image icon, which looks far worse than having no thumbnail at all, because a broken image reads as broken software, not as pending data.

There's a subtlety that makes this case more common than you'd guess. Because the thumbnail's address is computed from the host, the URL always exists even when the image behind it doesn't — a brand-new site has a perfectly well-formed thumbnail address pointing at an object that was never captured. So the page can't trust the address; it has to treat the image failing to load as the ordinary thing it is.

So the serving side has to treat a missing image as a normal, expected state, not an error. When the real thumbnail isn't available, it falls back to something deliberate and clean — a branded placeholder rather than a broken box. Designing for the missing case is most of what separates a pipeline that looks reliable from one that shows its seams the first time a capture is late.

04Deterministic fallback

A fallback that looks like it was meant to be there

The placeholder got more thought than the feature seems to deserve, because a generic gray box screams "missing" as loudly as a broken image does. So the fallback isn't one shared blank tile — each site gets its own: I hash the hostname down to a single hue, paint the site's first initial on that color, and that's the tile. It's deterministic — a given site always gets the same tile and adjacent sites get different ones, so a grid full of fallbacks still reads as a set of distinct places, not a wall of identical error cards. There's no external favicon fetch either, which would be one more network call that can itself fail and drop you back at a broken image. The tile looks intentional because it is.

// deterministic tile — no network call, no chance of its own failure
let h = 0;
for (let i = 0; i < host.length; i++)
  h = (h * 31 + host.charCodeAt(i)) % 360;
// background hsl(h, 32%, 20%), foreground hsl(h, 45%, 72%),
// letter = host[0].toUpperCase()

The mechanism that makes it seamless is layering, not switching. The brand tile is painted underneath every card, always, and the real screenshot is layered on top of it. A captured image covers the tile completely; when the image is missing or errors while loading, an onError handler hides the failed image and the tile that was already sitting there shows through. There's never a broken-image icon, because the fallback isn't summoned after the failure — it's the floor the picture stands on, and a missing picture just reveals the floor.

05Capture lives elsewhere

The capture runs somewhere else entirely

What surprised me most is that the capture isn't part of the site's own build at all. It's a separate scheduled job in a different system, and the site only knows how to compute the deterministic address and render whatever is, or isn't, there. So when a fresh capture lands a new image in the store, the grid just starts showing it: no rebuild, no redeploy, no awareness that a capture happened. That decoupling is why the visual index stays current on its own — the thing that keeps it fresh and the thing that displays it don't share a release.

The one place the capture is strict is deciding what counts as a real thumbnail. It refuses to ship a bad render: a page that didn't return 200, a near-empty page with almost no text, a CAPTCHA interstitial standing in for the real site — all rejected, so a failed or hostile load can't masquerade as a genuine preview. A missing thumbnail degrades to the honest brand tile, which is fine; a confident screenshot of an error page would be a lie, which is not. It's the same quality-gate instinct I apply everywhere — the pipeline would rather show nothing than show something wrong.

This isn't hypothetical. When Flag Football Planet joined the network, it showed the brand tile for a while — added after the last full capture, so its stored object didn't exist yet and the address 404'd. The fix wasn't a code change or a site rebuild; it was one targeted capture run for that single host — re-photograph one site without re-photographing all of them — after which the store served a real image and the next page load swapped the tile for the screenshot.

# capture just one host, not the whole network
capture_network_thumbnails --hosts flagfootballplanet.com
# → headless render → WebP → object store at the deterministic key

06Small polish, big payoff

Small polish, network-scale payoff

A thumbnail is a tiny thing, and a pipeline to generate thumbnails automatically is a lot of machinery for a tiny thing when you could just take screenshots. The reason it's worth it is scale and time. At one or two sites, by hand is fine. At a network that grows and changes, manual is a debt that compounds, and the automated version is what lets the visual index stay true without becoming a chore I'll inevitably let slide.

It's the same trade I make all over the place: invest in a small automated pipeline so a thing that would otherwise depend on my ongoing attention instead maintains itself. The visible output is a nice grid of site previews. The real output is that the grid stays honest as the network evolves, with no human in the loop keeping it current — which, for anything that must stay true across many items, is the only version that survives a busy year.