← Writing
The Editorial · Engineering

The card your link becomes — Open Graph, crawlers, and the caches you don't own

Issue 020Aug 1, 202623 min read

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.

between the paste and the pictureyou paste a linksite.com/post/their crawlerGET /post/no JavaScriptno cookiesreads the <head>gives up fastog:imagesite.comthe crawler is a second visitor — same URL, none of your JavaScriptwhatever it finds in the head becomes the ad your link turns into
Fig 1Paste, fetch, card. The crawler visits the same URL you do, with almost none of the capabilities your browser has.AI-generated figure

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 head, as the crawler reads itog:titleHow images workthe bold line · 60–70 charsog:descriptionI built a tool that…one or two lines, then cutog:image…/og/post.9f2c.png90% of whether it's clickedog:urlsite.com/post/canonical — and a cache keyog:typearticlearticle · website · profileplus twitter:card = summary_large_image, or X shrinks you to a thumbnailevery URL absolute, every value server-rendered
Fig 2The five that matter, and what each one buys you. Everything else in the Open Graph vocabulary is refinement.AI-generated figure
index.html — the head a crawler actually needsHTML
<!-- 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.

one set of tags, five readersplatformimage budgetre-scrapehow to force itFacebook · WhatsApp≤ 600 KB~30 daysSharing DebuggerX (Twitter)≤ 5 MB~7 daysno purge — new URLLinkedIn≤ 5 MB~7 daysPost InspectorSlack · Discorda few MBhours–daysedit or repost the linkiMessage · Telegrama few MBper device@WebpageBot (Telegram)WhatsApp silently drops images over ~300 KB, and reads only the first300 KB of your HTML — design for the strictest reader, not the kindest
Fig 3The numbers that decide whether your card appears. WhatsApp is the strictest reader in common use; satisfy it and the rest follow.AI-generated figure

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.

what you draw vs what they see1200 × 6301.91 : 1dashed inset = safe area · square = where clients crop~340 px widein a phone chat, at arm's lengthset your title at 64–80 px on the canvas — anything smaller vanishesfive words, high contrast, nothing important within 60 px of an edge
Fig 4What you draw and what they see. Every typographic decision follows from the second rectangle, not the first.AI-generated figure

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.

where the picture comes froma file you drewone PNG in /publicsame on every pageyou update it by handcost · five minutesmade at buildone PNG per pagedrawn from the contentcan never driftcost · build minutesmade per request/og?title=… on the edgeany page, any paramsneeds a cache + a guardcost · a live servermost sites want the middle one — the content is already there at build timeand a file on a CDN never wakes up slow
Fig 5Hand-made, build-time, or per-request. The trade is between how much can change and how much has to be running.AI-generated figure

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.)

app/writing/[slug]/opengraph-image.tsx — one PNG per post, at buildTypeScript
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.

app/og/route.tsx — the per-request version, guarded and cachedTypeScript
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.

app/writing/[slug]/page.tsx — the head, rendered on the serverTypeScript
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.

three caches, three different keysthe platform's metadata cachekey · your page URLtheir image proxykey · the image URLyour CDNkey · the image URLyour origin — the truththe new bytesnot yoursyoursnew bytes at the same URL change nothing above the dashed line
Fig 6The request path, with the cache key at each stop. Only the bottom two are yours, and the top one is the one showing the stale card.AI-generated figure

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.

the same change, two URL schemesv1 is out thereyou edit the titlea crawler returnsserves v1 · stale/og/post.pngv1 is out thereyou edit the titlea crawler returnsnew URL · fresh/og/post.9f2c1a.pngone URL forever — the cache is right to keep ithash the inputs and the URL changes itself"remember to bust the cache" stops being a step you can forget
Fig 7A fixed path lets every cache do its job correctly and show the wrong thing. A content-addressed path has no old version to serve.AI-generated figure
lib/og.ts — let the content pick its own URLTypeScript
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.

vercel.json — hard-cache the hashed file, keep the HTML freshJSON
{
  "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.

check-og.sh — see exactly what a crawler seesBash
# 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, immutable

Then 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.

All issues