- Oct 8, 2026
- 5 min read
Checking og:image Properly: Size and Format from the First Bytes
When someone shares a link on LinkedIn, WhatsApp or Slack, the preview
picture comes from the page’s og:image tag. My website audit checked for
that tag, and my three sites all passed.
Then I looked at what the tags pointed to:
| Site | og:image | What it really was |
|---|---|---|
| www | social-logo.png | PNG, 248×56, a wordmark. A blank or stretched card. |
| ebooks | JAlcocerTech-logo.png | The same 248×56 wordmark. |
| consulting | og-image.png | A JPEG named .png, 1024×1024 and 675 KB, cropped in previews and still showing an old brand. |
A check that only looks for the tag would pass all three. So the audit now downloads the image and looks at it.
What gets checked
| Check | Severity | Why |
|---|---|---|
| Not reachable (404, timeout, private address) | high | No picture at all |
| Not an image, or SVG | high | Platforms don’t show SVG previews |
| Smaller than 600×315 | high | A thumbnail or nothing |
| Ratio far from 1.91:1 | medium | Cropped in large cards |
| WebP or AVIF | medium | LinkedIn skips WebP share images |
| Relative URL | medium | Some crawlers need an absolute https:// URL |
| Over 1 MB / over 5 MB | medium / high | Slow, or dropped entirely |
| Over 300 KB | low | WhatsApp may skip the preview |
| Declared type ≠ real type | low | Strict crawlers may reject it |
The recommendation everywhere is the same: 1200×630, JPEG or PNG, under 300 KB.
Format and size from the bytes
The Content-Type header and the file extension can’t be trusted (see the
JPEG named .png), and decoding the whole image to get its size would need
a dependency. But every common format records its size near the start of
the file:
export function sniffImage(buf) {
const ascii = (a, b) => buf.toString('latin1', a, b);
// PNG: signature, then the IHDR chunk with width and height (big-endian)
if (buf[0] === 0x89 && ascii(1, 4) === 'PNG')
return { format: 'png', width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
// GIF: logical screen size, little-endian
if (ascii(0, 4) === 'GIF8')
return { format: 'gif', width: buf.readUInt16LE(6), height: buf.readUInt16LE(8) };
// JPEG: walk the segments until a start-of-frame marker
if (buf[0] === 0xff && buf[1] === 0xd8) {
let i = 2;
while (i + 9 < buf.length) {
if (buf[i] !== 0xff) { i++; continue; }
const marker = buf[i + 1];
if (marker === 0xff) { i++; continue; } // fill byte
const isSof = marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker);
if (isSof) return { format: 'jpeg', width: buf.readUInt16BE(i + 7), height: buf.readUInt16BE(i + 5) };
if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { i += 2; continue; }
i += 2 + buf.readUInt16BE(i + 2); // skip this segment
}
return { format: 'jpeg' };
}
// WebP: RIFF container, three chunk variants (lossy, lossless, extended)
if (ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WEBP') { /* VP8 / VP8L / VP8X */ }
// AVIF: ISO-BMFF; the size lives in the 'ispe' box
// SVG: starts with <svg or <?xml
}
The JPEG walk is the only fiddly part. JPEG is a list of segments, each
starting with 0xFF plus a marker byte and (mostly) a 2-byte length. The
size is in the first start-of-frame segment (0xC0–0xCF, except 0xC4,
0xC8 and 0xCC, which are other things): height first, then width.
Fetching someone else’s URL safely
The image URL comes from the page being audited, which is effectively a
stranger’s input. If the tool simply fetched it, a page could point
og:image at http://192.168.1.1/ and use my server to probe my own
network. The fetch reuses the audit’s existing guards
(running strangers’ URLs safely):
http(s)only, no credentials in the URL;- resolve the hostname and refuse private, loopback and link-local addresses;
- follow redirects manually, one hop at a time, checking each new location the same way (at most three);
- stop reading after 6 MB, with a 10-second timeout.
Those helpers used to live inside the server; they moved into a shared module so the SEO script could use them too.
A bug in my own scoring
With the check in place, the SEO score… stayed at 100.
The page checks add up to 115 points before the score is capped at 100, so a 10-point penalty disappeared into the spare 15. The penalty is now applied after the cap:
score: clamp(clamp(score) - ogImagePenalty(ogImage)),
If you build an additive score with a cap, check whether the maximum really is 100. Spare points hide penalties.
In the report, share-image problems get their own priority, “Fix how the site looks when shared”, because they’re about social posts, not search.
Results on my own sites
All three went from SEO 100 (false) to 94–95 (true), and back to 100 after real share cards.
Takeaways
- Check the asset, not just the tag. That applies to favicons, sitemaps and manifests too.
- Read format and size from the bytes. Headers and extensions are often wrong.
- Any URL found inside a page is untrusted input.
- Spare points in a score hide penalties.