Growth

One social card for every post is one card nobody shares

A single site-wide social card passes validation and makes every shared link identical. Per-post cards need generating, verifying, and a scan for the fallback.

MobileByteSensei 10 min read
Four impressions that say nothing, and one that identifies its document
seoassetspublishing

A link to a post is pasted into a chat and expands into a preview card. The card carries the site's logo on a coloured field, which is what every other link from the site has carried.

Nothing is broken. A fallback image is a correct implementation of the specification.

The case this post is about

One share card, used by every post on the site.

It resolved, it was the right dimensions, it carried the site's name and mark, and it passed every automated check there is — because every check asks whether the image exists, and it did.

What it could not do is distinguish one link from another. Every post shared to every platform looked identical, so a reader who had seen one had no visual reason to think the next was new.

This post follows that one card, because the fix produces two images per post rather than one — the titled version and a text-free twin for contexts that draw their own title — and the check that matters is not "does a card exist" but "is this post's card its own".

The symptom

The visible version is a conversation where somebody shares three of your posts and the result is three identical rectangles. The titles are there in small text underneath, the images are the same, and the thing the eye actually uses to tell them apart is doing no work at all.

The second thing is that every check passes. The meta tag is present, the URL resolves, the image is the right dimensions, the validators are happy. A site-wide fallback is not a missing card; it is a card, and no tool that inspects one page at a time can see what is wrong with it, because what is wrong with it is a property of the set.

The third is where the fallback comes from, which is almost always reasonable. A default is set early so that nothing ever has a broken preview, which is correct. Then per-post generation is added for new posts, the default stays as the safety net, and the posts that predate the generator — or whose generation failed quietly — keep using it indefinitely with nothing reporting the difference.

The fourth is that it fails exactly when it matters. A post gets shared because somebody found it useful, which is the one moment the preview is doing real work, and that is when the generic card appears. The posts with the most sharing are the ones where the defect is most expensive, and there is no feedback path that tells you.

There is a fifth that is structural rather than visual. Preview cards are one of the few surfaces where your content competes directly against everything else in somebody's feed or chat, and the competition is decided in about a second on image alone. A generic card does not lose that comparison slightly; it opts out of it, by being the one thing in the list that carries no information.

Why the obvious fix failed

Generate a card per post. Necessary and not sufficient on its own, because generation can fail per post and the fallback catches it silently. A pipeline that produces cards for most posts and quietly falls back for the rest looks identical, from any single page, to one that works.

Check the meta tag is present. Checks the wrong property. The tag is present in the failure case — that is what makes the failure survive. A useful check compares the resolved URL against the known fallback, which is a different assertion entirely.

Use the post's own header image. Tempting and wrong in a specific way: a social card is seen alone, out of context, usually at a small size, and often beside competing links. An editorial image that works inside a post, under its headline, has none of that context when it is the card — so it reads as a decorative rectangle rather than as a claim about what the link is.

Add the title in the preview text and let the image be decorative. This is what the fallback is implicitly doing. It works if people read preview captions, and the honest observation is that they scan images first, which is the entire reason the card exists as a surface.

It is also worth being honest that the fallback wins on one axis: it is always correct. Nothing about it can be stale, mismatched or missing, which is a real property and the reason it survives in so many codebases. The argument for per-post cards is not that the fallback is broken — it is that being always-correct and never-useful is a poor trade for a surface this visible.

The actual mechanism

Generate a card per post, containing the post's own headline, and then verify that the thing actually referenced is not the fallback.

generate   one card per post, with that post's title set into the image
twin       a text-free version of the same art for contexts that add their own title
reference  the post's frontmatter points at its own card
scan       compare every resolved card URL against the fallback URL

The second line is the part that is easy to leave out and awkward to add later. Some surfaces render their own title next to the image, so a card with the headline burned in shows the title twice; others show the image alone, where a card without a headline says nothing. Producing both from one source — the same artwork, one with type and one without — costs nothing at generation time and cannot be reconstructed afterwards without regenerating everything.

One device, struck two ways, in a single operation

The sub-case that determines whether the whole thing holds is the scan. A fallback that is never used is a good safety net; a fallback that is used by thirty posts is a defect wearing a safety net's clothes, and the only way to tell which you have is to resolve every post's card and count how many point at the default. That check is a few lines, it runs on the whole corpus, and it is the only one that can see a property of the set.

The whole run laid out, because the repetition is only visible across it

Scans of this shape have a second benefit worth taking: they are the natural home for every other whole-corpus assertion you will want later. Once something enumerates every post and resolves an asset per post, adding "and the file is non-empty", "and the dimensions are right", "and it was deployed" costs almost nothing, and each of those is a failure that is invisible one page at a time.

There is a related failure worth naming because it is invisible for a different reason: the card that is generated correctly and never deployed. The file exists locally, the frontmatter points at the right URL, and the asset was not published — so the preview falls back to nothing at all, which looks like a platform problem rather than a missing file.

The fix

generate   per post, title set into the image, from the post's own frontmatter
produce    both the titled card and the text-free twin, in the same step
verify     each post's resolved card URL is its own, not the fallback
count      how many posts use the fallback; the target is zero and it is checkable

The generator and the audit are one mechanism: the card is derived from the post's own frontmatter, and the audit asks whether the resolved URL is the post's own file rather than the shared default.

export async function cardFor(post: Post) {
  const base = `posts/${post.slug}/assets`;
  // The TITLED card, and the text-free twin, in the same step. Producing them
  // separately is how a post ends up with one and not the other.
  const art = await renderArt({ title: post.meta.title, section: post.meta.category });
  await Deno.writeFile(`${base}/card-bare.png`, art);
  await Deno.writeFile(`${base}/card.png`, await overlayTitle(art, post.meta.title));
  return `/posts/${post.slug}/assets/card.png`;
}

const FALLBACK = "/assets/og-default.png";

export async function auditCards(posts: Post[]) {
  const rows = await Promise.all(posts.map(async (p) => {
    const html = await Deno.readTextFile(`dist/blog/${p.slug}/index.html`);
    const url = /<meta property="og:image" content="([^"]+)"/.exec(html)?.[1] ?? "";
    const own = url.includes(`/posts/${p.slug}/`);
    const exists = own && await fileExists(`dist${url}`);
    return { slug: p.slug, url, verdict: !own ? "FALLBACK" : exists ? "ok" : "MISSING FILE" };
  }));
  const bad = rows.filter((r) => r.verdict !== "ok");
  return { fallbackCount: bad.filter((r) => r.verdict === "FALLBACK").length, bad };
}

The audit reads the built HTML, not the source — because the question is what a crawler receives:

$ deno task audit-cards
  three-phases          ok
  measuring-a-blog      FALLBACK      /assets/og-default.png
  og-image-per-post     MISSING FILE  /posts/og-image-per-post/assets/card.png
fallback: 1 · missing: 1 · of 253

MISSING FILE is the one a source-side check cannot see: the tag is correct and personalised, and the file it points at was never copied into the build.

The fourth line is what converts this from a thing you believe to a thing you know. "Every post has its own card" is a claim about two hundred files; a count of fallback users is a number, and a number that is not zero names the posts.

What makes the generation affordable is that it is the same operation every time: one artwork, one headline, one layout, two outputs. The expensive version of this is a bespoke image per post, which is why most sites do not do it — and the version that actually matters is just the title, legible, on something that is not the same rectangle as last time.

How we proved it

The corpus is countable and the count is the whole evidence. Two hundred and seventeen posts, two hundred and seventeen titled cards, two hundred and seventeen text-free twins. That is not a claim that the system works; it is the output of counting the files, which is what makes it worth stating.

Uniform because everything is generic, then uniform because everything is its own

The only post without a card was this one, at the time of writing, which is the kind of coincidence that makes a scan worth having rather than a memory. Nothing drew attention to it. It showed up because the check enumerates every post directory and reports the ones missing an asset, and it would have shipped otherwise.

What I cannot tell you is the effect on sharing. I have no comparison between the fallback period and the per-post period, because the change was not run as an experiment and the traffic is too small to separate from everything else that moved. The argument is that three identical rectangles are obviously worse than three different ones, not that I measured the difference.

What I have not solved: the platforms that cache aggressively. A card updated after a link has been shared may continue showing the old image for a long time, sometimes indefinitely, and the refresh mechanisms are per-platform, manual, and not always effective. Getting the card right before the first share is worth more than it sounds, because the first version is often the one that persists.

And a limitation of burning the title into the image: it is now in two places, and they can disagree. Rename a post and the card still carries the old headline until it is regenerated, which is a drift of exactly the kind this publication's other checks exist to catch. Mine regenerates from the frontmatter, so the fix is to re-run it — and nothing currently notices that it needs re-running.

The thing I would tell somebody is that the fallback is not the enemy and the silence is. A default image is the right thing to have; what makes it dangerous is that nothing distinguishes "nobody needed the fallback today" from "a third of the corpus has been using it since March", and that distinction is one small scan away.

What this generalizes to

A fallback that works perfectly is indistinguishable from a feature that never ran, and almost every system has several.

The shape is everywhere once you look for it. A default locale serving users whose translation failed to load. A placeholder avatar covering a broken upload path. A default configuration masking one that was never read. A generic error message standing in for the specific one that was not written. In each case the system is behaving as designed, the user sees something reasonable, and the thing that was supposed to happen did not.

So the useful question about any fallback is how would I know how often it fires — because a fallback with a counter is a safety net, and a fallback without one is a place where features go to quietly not exist.

The counter does not need to be elaborate. Knowing that the default fired zero times this build, or six, is enough to tell you which of the two things you have — and the moment that number is in front of somebody, a fallback stops being a decision nobody revisits.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS