carddoctor

every check, explained

this is the exact list card doctor runs. none of it is theory — each entry broke a real card in production at least once. grouped the way you should fix them: meta first (free), then the image, then the transport, then the stuff you cannot rush.

the meta

twitter:card present and valid

why it breaks cards: without <meta name="twitter:card" content="summary_large_image"> x falls back to whatever it can guess, and guesses small. fix: set the tag explicitly. summary_large_image for the full-bleed image, summary for a thumbnail.

title & description exist

why: an empty title or description renders an empty-looking card. fix: set og:title / og:description (or twitter: variants). x truncates descriptions around 200 characters — front-load the point.

a card image exists (twitter:image / og:image)

why: no image tag, no image — the single most common cause. fix: point it at an absolute https url. x reads twitter:image first, falls back to og:image.

canonical url signals (rel=canonical, og:url)

why: if you share cache-busting variants (?s=2) to escape x's poisoned cache, a canonical/og:url can collapse every variant back onto the root's cached card. fix: keep rel=canonical for google if you like, but drop og:url when you rely on variants.

the image

image is fetchable (2xx)

why: a 403/404/timeout here is an instant no-image card. fix: the url must be publicly fetchable — no auth, no hotlink protection, no waf rules blocking datacenter crawlers.

image content-type is actually an image

why: a file served as application/octet-stream (a missing entry in your server's mime map — we shipped exactly this bug) gets rejected even when the bytes are a perfect jpeg. fix: serve image/jpeg or image/png.

image framing: content-length, not chunked

why: browsers tolerate chunked responses; some image pipelines are pickier. fix: set an explicit Content-Length on static files. one line in most servers.

image size & dimensions

why: over 5mb is rejected; under 600×315 renders tiny or not at all. fix: 1200×630 (1.91:1), well under 5mb.

image format detail

why: progressive jpegs and interlaced pngs trip older decoders in some pipelines. fix: export baseline jpeg / non-interlaced png.

no query string in the image url

why: x's image fetcher is historically flaky with og.png?v=3-style urls — we lost days to this. fix: version in the filename instead: og-v3.jpg.

the transport

tls certificate verifies

why: an incomplete chain works in browsers (they fetch intermediates) but fails for many crawlers. fix: serve the full chain; test from a clean client, not your browser.

robots.txt doesn't block the bot

why: a Disallow: / (or a twitterbot-specific rule) kills the crawl at the door. fix: allow it explicitly. a missing robots.txt (404) is fine.

redirect chain is short

why: long chains (http→https→www→apex→page) burn the crawler's patience. fix: one hop max to the final url.

html isn't no-cache

why: some card caches re-fetch no-cache pages on every render attempt instead of storing the metadata once. fix: a short public max-age on html is fine.

response times

why: crawlers give up on slow origins, especially for the image. fix: page and image should each answer in a couple of seconds from a us vantage.

the stuff you cannot rush

domain age (rdap)

why: x withholds card images for very young domains even when every byte is correct — verified in production with a domain that passed all other checks. fix: age and real shares heal it; or bypass the image leg entirely by hosting the cover on pbs.twimg.com (upload once in any tweet, copy the image address) — x always renders its own cdn.

the card cache

why: x locks a url's card at first crawl and the old validator is dead. fix: you cannot purge; test and share on fresh query-parameter urls.

← run your url through the checks · built from a production incident at letsconnect.space