The card your link becomes — Open Graph, crawlers, and the caches you don't own
Paste a URL into WhatsApp and something decides, in about two seconds, whether the link looks like a product or like spam. That something is a crawler reading fifteen lines of your <head> — no JavaScript, no cookies, no patience. This is what it reads, why the picture is wrong on exactly one platform, and the one structural change that means you never have to remember to bust a cache again.
The issue
You ship the page. You paste the link into the group chat, or the founders' Slack, or a post you have rewritten four times — and what appears underneath your sentence is a grey rectangle with a broken-image glyph, the word site.com, and no title. It looks like the links people are told not to click. Nobody clicks it.
This is worth more attention than it usually gets, because the card is seen far more often than the page. A link that travels through ten group chats is a hundred impressions of a small rectangle and maybe eight visits. That rectangle is an advertisement you shipped by accident, and unlike everything else on your site you cannot see it from your own browser. It is assembled somewhere else, by a program you never test against, out of about fifteen lines of your HTML.
So this issue is that program: what it fetches, what it reads, why the picture is right everywhere except one app, and — the part that costs teams the most time — why fixing the image often changes nothing for a week. That last problem has a clean structural answer, and once you have it, clear the cache stops being a step in your release checklist.
What happens in the two seconds after you paste
The moment a URL lands in a message box, the app sends its own crawler out to fetch it. Slack calls the result an unfurl, which is as good a name as any: the link unfolds into a card. Facebook and WhatsApp send facebookexternalhit, X sends Twitterbot, LinkedIn sends LinkedInBot, Slack sends Slackbot-LinkExpanding. They are different programs written by different teams, but they all do the same three things: GET your URL, parse the <head>, and throw the rest away.
Hold on to that middle box, because every strange bug in this area comes out of it. The crawler runs no JavaScript. It sends no cookies and has no session, so anything behind a login is a login page to it. It follows one or two redirects and then stops. It has a short timeout — a few seconds, not thirty. And it reads the head, not the page: your beautiful hero image is irrelevant, and so is everything below it.
Which means the card is decided entirely by markup that must already exist in the HTML the server sends. Not markup your app adds after hydration. Not a title you set in a useEffect. If your site is a client-rendered single-page app that injects meta tags at runtime, every link you have ever shared has shown the same generic shell — usually the app name and nothing else — and no amount of correct tag-writing fixes it, because nobody is running the code that writes them. The fix is to render the head on the server: any static export, any server-rendered framework, or a prerender step that produces real HTML per route.
The five tags that do the work
The vocabulary is Open Graph, a small specification Facebook published in 2010 that everyone else quietly adopted. The tags live in the head, use property rather than name, and there are dozens of them. Five carry the card.
<!-- The five that do the work. Every URL absolute, always https. -->
<meta property="og:title" content="How images actually work" />
<meta property="og:description" content="A from-scratch primer on pixels, compression, and why WebP wins." />
<meta property="og:image" content="https://site.com/og/how-images-work.9f2c1a.png" />
<meta property="og:url" content="https://site.com/writing/how-images-work/" />
<meta property="og:type" content="article" />
<!-- Describe the image before it is downloaded. Some clients reserve the
space from these numbers and skip the card if the file is slow. -->
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="Issue 001 - Image to WebP" />
<meta property="og:site_name" content="Subhra Jyoti Baroi" />
<!-- X reads its own tags first and falls back to og:* for anything
missing. This single line is a banner instead of a thumbnail. -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@baroiSubhra" />
<!-- Not a social tag, but the crawlers read it, and it settles which of
your near-duplicate URLs is the real one. -->
<link rel="canonical" href="https://site.com/writing/how-images-work/" />og:title is the bold line. Sixty to seventy characters survive on most clients; write it for a person glancing at a phone, not for a search engine. It is allowed to differ from your <title> tag, and usually should — the page title is a filing label, the card title is a sentence.
og:description gets one or two lines and is then cut without ceremony. Some clients (iMessage, sometimes Slack) do not show it at all. Treat it as a bonus, never as the place the important information lives.
og:image is the card. It is most of the surface area and most of the reason anyone clicks. It has a page of rules of its own, which is the next section.
og:url is the one people skip, and it is the one that bites later. It declares the canonical identity of this page. Platforms use it to fold ?utm_source=twitter and ?ref=newsletter back into a single entity so that likes and shares accumulate in one place instead of scattering across near-identical links — and, as we will see, several of them use it as the key of the cache entry too. Point it at the clean, final URL, with the trailing slash your site actually serves.
og:type is usually website, article for a post (which unlocks article:published_time and article:author), or profile for a person. It changes little visually and costs one line.
Then twitter:card. X maintains a parallel vocabulary, reads it first, and falls back to Open Graph for anything absent — so you do not need to duplicate everything. But if you omit summary_large_image, you get summary: a small square thumbnail beside two lines of text, which throws away the picture you spent the afternoon on.
Same tags, five different readers
Open Graph is a specification in the loose sense that everyone implements the parts they like. The disagreements are not stylistic — they are hard limits, and each one fails quietly.
WhatsApp deserves special mention because it is the one most likely to be where your link is actually shared, and the one that fails most silently. It piggybacks on Facebook's crawler but is meaner about size: images much over ~300 KB tend to be dropped with no error and no fallback, and Meta's own guidance asks for the head to appear within the first 300 KB of the HTML. If you ship a 3 MB hero as your og:image, or you have an enormous inline stylesheet above your meta tags, WhatsApp shows a bare link while every other app shows a perfect card — and you will spend an hour convinced it is a caching problem.
The other traps in that table are worth naming plainly. Anything requiring authentication is invisible — a preview deployment behind password protection previews as a login screen. A redirect chain longer than a hop or two gets abandoned. SVG is not supported anywhere, and WebP is inconsistently supported, so PNG or JPEG remains the safe answer for the card even on a site that serves WebP everywhere else. And a robots.txt that blocks unknown user-agents blocks the crawlers too — which is how a site can rank perfectly in search and still preview as a grey box.
Designing a picture that will be seen at 340 pixels
The canvas is 1200×630 — the 1.91:1 rectangle every platform sizes for. That is the number everyone knows. The number that should actually drive the design is the one it is displayed at: roughly 340 points wide in a phone chat, which is a third of the size you are drawing it, on a screen held at arm's length, next to a keyboard, in a hurry.
So: five or six words, set at 64–80 px on the 1200-wide canvas, which lands around 20 px in the card. High contrast — a photograph behind text needs a scrim over it, not hope. Nothing that matters within about 60 px of any edge, because clients crop: LinkedIn and Slack sometimes present a squarer frame, and a title that reaches the edges loses its first and last word rather than scaling down.
One more habit worth adopting: the card should carry the page's own content — this post's title, this product's name — not a logo. A single site-wide image means every link you share looks identical, which is exactly the visual signature of a spam account. Per-page cards are the whole reason the generation strategies in the next section exist.
Three ways to produce the picture
You can draw them by hand, you can generate them when the site builds, or you can generate them when someone asks. The middle option is right for most sites, and it is worth knowing why the other two exist.
A hand-drawn file in /public is a perfectly good answer for a five-page marketing site, and a bad one the moment you have posts, because it will be one image for all of them and it will drift out of date the first time anybody is busy.
Build-time generation is the sweet spot. The title, the date, and the tag are already in hand when the site compiles, so a card can be drawn from them and emitted as a real PNG next to the page. In Next.js the tool is next/og, which runs Satori — a small renderer that turns a subset of JSX and CSS into SVG, then into a PNG. On a statically exported site it runs entirely at build and there is no image server at all afterwards, just files. (That is what this site does: one PNG per issue, generated from the same data that renders the page, so a card can never disagree with its post.)
import { ImageResponse } from "next/og";
import { readFileSync } from "fs";
import { join } from "path";
import { POSTS } from "../../data";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export const dynamicParams = false;
// One card per published post — this is what turns the route into a
// set of static files instead of a runtime endpoint.
export function generateStaticParams() {
return POSTS.map((p) => ({ slug: p.slug }));
}
// Satori has no access to the network or to installed fonts: every face
// you use has to be handed over as bytes. Subset them, or a 400 KB font
// file becomes part of every build.
const font = readFileSync(join(process.cwd(), "assets/og/Inter-Medium.ttf"));
export default async function Image({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = POSTS.find((p) => p.slug === slug)!;
return new ImageResponse(
(
// Satori supports flexbox, not grid; every element with more than
// one child needs an explicit display:flex. CSS variables and
// color-mix() do not resolve here - use concrete values.
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
padding: "72px 64px",
backgroundImage: "linear-gradient(160deg, #1a1614 0%, #2a2520 100%)",
color: "#faf7f2",
fontFamily: "Inter",
}}
>
<div style={{ fontSize: 30, letterSpacing: "0.14em", opacity: 0.8 }}>
{post.issue.toUpperCase()}
</div>
{/* ~72px here is ~20px in a phone card: the readable floor. */}
<div style={{ fontSize: 76, lineHeight: 1.05, letterSpacing: "-0.02em" }}>
{post.title}
</div>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 28, opacity: 0.75 }}>
<span>{post.tag}</span>
<span>{post.read}</span>
</div>
</div>
),
{ ...size, fonts: [{ name: "Inter", data: font, weight: 500, style: "normal" }] },
);
}
export const alt = "Issue cover";Per-request generation — a single /og endpoint that takes a title as a query parameter — is the right call when the content genuinely is not known at build: user profiles, dashboards, anything with a long tail of pages. Two things are then non-negotiable. Cache the response, or every crawler visit re-renders an image. And guard the inputs, because an open renderer that draws arbitrary text on your branded template, on your bill, is a gift to anyone who finds it.
import { ImageResponse } from "next/og";
import { getPost } from "@/lib/posts";
export const runtime = "edge";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
// Guard. Never render free-text straight from the query string: that
// is an open image renderer with your logo on it. Accept an id you
// control (or a signed token) and look the content up yourself.
const post = getPost(searchParams.get("slug") ?? "");
if (!post) return new Response("Not found", { status: 404 });
return new ImageResponse(<Card title={post.title} tag={post.tag} />, {
width: 1200,
height: 630,
headers: {
// The caller's URL carries a content hash (next section), so this
// response can never go stale - cache it as hard as possible.
"cache-control": "public, max-age=31536000, immutable, no-transform",
},
});
}Whichever you pick, the head is written the same way. In Next.js that is the metadata API, and the only real discipline is that every URL must be absolute — a relative og:image is the most common reason a card has no picture at all, because the crawler is not resolving it against your page the way a browser would.
import type { Metadata } from "next";
import { getPost } from "@/lib/posts";
import { ogImageUrl } from "@/lib/og";
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const post = getPost(slug);
if (!post) return { title: "Writing" };
const url = `https://site.com/writing/${post.slug}/`;
return {
title: post.title,
description: post.dek,
alternates: { canonical: url }, // <link rel="canonical">
openGraph: {
type: "article",
url, // og:url - the page's identity
siteName: "Subhra Jyoti Baroi",
title: post.title,
description: post.dek,
publishedTime: post.isoDate,
images: [
{
url: ogImageUrl(post), // absolute, content-hashed
width: 1200,
height: 630,
alt: post.title,
},
],
},
// X falls back to og:* for title/description/image, so this is
// just the card shape and the attribution.
twitter: { card: "summary_large_image", site: "@baroiSubhra" },
};
}Now the hard part: the caches you don't own
Everything so far is the easy half. Here is the half that eats afternoons: you fix a typo in the card, redeploy, paste the link into LinkedIn — and the old card comes back. You clear your CDN. Old card. You open an incognito window. Old card. You start to suspect the deploy did not happen.
It did. There are three caches between your change and that rectangle, and they are keyed by different strings.
The top layer is the expensive one. When a platform scrapes your page it stores the result — title, description, image URL, often a re-hosted copy of the image itself — against your page URL, and serves that stored blob to everyone who shares the link until it decides to look again. Facebook and WhatsApp keep it for about thirty days; X and LinkedIn for about a week; Slack and Discord for something shorter and undocumented. You have no API to invalidate it, no header that shortens it. Cache-Control is advice they are free to ignore, and they do.
Below that sit the caches keyed by the image URL: the platform's own image proxy (most re-host your picture so posts do not break when your site goes down), then your CDN, then the reader's device. And this is the trap in one sentence — if you regenerate /og/post.png with new bytes at the same path, not a single cache key anywhere in that stack has changed. Every layer is behaving exactly as designed. There is nothing to purge; there is only something to name differently.
The rule: change the content, change the URL
Manual cache-busting — appending ?v=2, then ?v=3 — is the right idea executed by the wrong agent. It works because it changes the key. It fails because a human has to remember, and the human who forgets is the one shipping at 11 p.m.
So hand the job to the content. Derive the filename from a hash of everything the picture is drawn from, and the URL updates itself the instant any of those inputs move. A stale card stops being possible — not unlikely, impossible — because the new image lives at a URL no cache on Earth has an entry for.
import { createHash } from "crypto";
// Bump when the *template* changes. A redesign must invalidate every
// card even though no post's text moved - without this, a new layout
// ships to nobody for a month.
const TEMPLATE_VERSION = "v3";
export function ogImageUrl(post: Post): string {
// Hash exactly the inputs the picture is drawn from. Anything you
// leave out is a change that will not bust the cache; anything you
// add that is unrelated (a build id, a timestamp) busts every card
// on every deploy and throws away warm caches for nothing.
const fingerprint = createHash("sha1")
.update([TEMPLATE_VERSION, post.title, post.dek, post.tag, post.date].join("\u0000"))
.digest("hex")
.slice(0, 8);
// Absolute, https, stable, and different the moment anything above
// changes: /og/how-images-work.9f2c1a.png
return `https://site.com/og/${post.slug}.${fingerprint}.png`;
}Two details in that function are the whole design. Hash the inputs, not the deploy: using a commit SHA or a build timestamp technically busts the cache, but it busts every card on every deploy, so each release throws away warm CDN and platform caches for a typo fix in the footer. And include a template version, because otherwise a redesign of the card changes no post's text, produces no new filenames, and silently ships to nobody until the platform caches expire on their own schedule.
Now the second half, which is the one people miss. Content-addressed image URLs guarantee the image is never stale — but the platform only learns about the new URL when it re-reads your HTML. So the HTML must not be cached the way the image is. Hashed filenames get the full immutable year; the page itself gets a short life, so the next crawler that comes round picks up the new reference immediately.
{
"headers": [
{
"source": "/og/(.*)",
"headers": [
{
"key": "Cache-Control",
"comment": "The filename contains a content hash, so these bytes can never be wrong. Cache for a year, everywhere, and never revalidate.",
"value": "public, max-age=31536000, immutable"
}
]
},
{
"source": "/writing/(.*)",
"headers": [
{
"key": "Cache-Control",
"comment": "The HTML is the thing that announces the new image URL. Serve it fast from the edge, but let it go stale in minutes, not months.",
"value": "public, max-age=0, s-maxage=300, stale-while-revalidate=86400"
}
]
}
]
}(comment is not a real header field — it is there for the reader. Delete those two lines before you ship the file.)
Put together, the release story becomes: you edit a title, the hash changes, the new page HTML points at a URL nobody has ever fetched, and the first crawler to come back gets a card that is correct by construction. You did not clear anything. There was nothing to clear.
When you need it fixed now
Content-addressing removes the recurring problem. It does not help with the launch tweet you sent forty minutes ago carrying a typo, because the platform is still holding a metadata blob keyed to that page URL. For that you need a re-scrape, and the levers are uneven.
Checking it before anyone sees it
You cannot inspect a card from your browser, but you can be the crawler. Two curl commands answer nearly every question: what is in the head as served, and is the image actually fetchable, of the right type, and under budget.
# 1. The head, as delivered - no JS, no cookies, crawler user-agent.
# If a tag is missing here, it does not exist as far as any platform
# is concerned, however good it looks in devtools.
curl -sL -A 'facebookexternalhit/1.1' https://site.com/writing/og-images/ \
| grep -Eo '<meta (property|name)="(og|twitter):[^>]+>'
# 2. The image itself. HEAD only, following redirects, checking the
# four things that actually decide whether the card renders.
curl -sIL 'https://site.com/og/og-images.9f2c1a.png' \
| grep -Ei '^(HTTP/|content-type|content-length|cache-control)'
# HTTP/2 200 <- not 302 to a login, not 403
# content-type: image/png <- not text/html, not image/svg+xml
# content-length: 214883 <- under WhatsApp's ~300 KB cliff
# cache-control: public, max-age=31536000, immutableThen the hosted validators, which show you the rendered card rather than the tags: Facebook's Sharing Debugger, LinkedIn's Post Inspector, and — since X no longer previews — any of the third-party preview tools for a rough approximation. Test one real post, not the homepage; the homepage is the page whose tags are always right.
If you want this to stay fixed, the curl above is also a perfectly good CI check: fetch the built HTML for a handful of routes, assert the five tags are present and absolute, then HEAD the image URL and fail the build if it is not a 200 with an image/* content type under 300 KB. It is fifteen lines of shell and it catches the entire class of failure this issue is about.
The short version
None of this is difficult work. It is just work that happens outside the browser you develop in, on machines you cannot log into, stored in caches you cannot reach — which is why it tends to be discovered on the day it matters most. Get the head server-rendered, let the content name the file, and the whole category quietly stops being a thing you think about.