How to check og image

Read the og:image value, then fetch it and measure what came back. The tag has to hold an absolute URL. Check the status after redirects, the content type, the byte size and the real pixel dimensions, and compare those against og:image:width and og:image:height where the page declares them.

Checker offline. Follow the manual steps below, they give the same answer.

Why check this

Run this whenever the card image changes, when the asset pipeline changes its output path, and on staging sign-off for any template shipping a share card. The failure it prevents is the grey box: the tag is present, the review passes on reading the head, and nobody fetched the file behind the URL.

Four defects account for most of them. A relative URL, which has no base once the value leaves the page. A redirect, which some consumers follow and some do not. A 404 answered with an HTML error page. And declared dimensions that no longer describe the file. Reading the tag catches none of the four. Fetching the file catches all of them.

Prerequisites

// fixtures.js
const http = require('http'), fs = require('fs'), path = require('path');
const TYPES = { '.html': 'text/html; charset=utf-8', '.png': 'image/png' };
http.createServer((req, res) => {
  const url = req.url.split('?')[0];
  if (url === '/img/moved.png') return res.writeHead(302, { location: '/img/card.png' }).end();
  if (url === '/img/missing.png') return res.writeHead(404, { 'content-type': 'text/html' }).end('<h1>404</h1>');
  const file = path.join(__dirname, 'fixtures', path.normalize(url).replace(/^[\\/]+/, ''));
  if (!file.startsWith(path.join(__dirname, 'fixtures')) || !fs.existsSync(file)) {
    return res.writeHead(404).end('not found');
  }
  const body = fs.readFileSync(file);
  res.writeHead(200, { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream',
                       'content-length': body.length });
  res.end(body);
}).listen(8919, '127.0.0.1', () => console.log('fixtures on http://127.0.0.1:8919, pid ' + process.pid));
// makepng.js: two real PNGs, so the byte counts below are reproducible
const zlib = require('zlib'), fs = require('fs');
function crc32(buf){let c,t=[];for(let n=0;n<256;n++){c=n;for(let k=0;k<8;k++)c=c&1?0xedb88320^(c>>>1):c>>>1;t[n]=c;}
 let r=0xffffffff;for(const b of buf)r=t[(r^b)&0xff]^(r>>>8);return (r^0xffffffff)>>>0;}
function chunk(type,data){const len=Buffer.alloc(4);len.writeUInt32BE(data.length);
 const td=Buffer.concat([Buffer.from(type,'ascii'),data]);const crc=Buffer.alloc(4);crc.writeUInt32BE(crc32(td));
 return Buffer.concat([len,td,crc]);}
function png(w,h,rgb){const ihdr=Buffer.alloc(13);ihdr.writeUInt32BE(w,0);ihdr.writeUInt32BE(h,4);
 ihdr[8]=8;ihdr[9]=2;ihdr[10]=0;ihdr[11]=0;ihdr[12]=0;
 const row=Buffer.alloc(1+w*3);for(let x=0;x<w;x++){row[1+x*3]=rgb[0];row[2+x*3]=rgb[1];row[3+x*3]=rgb[2];}
 const raw=Buffer.concat(Array.from({length:h},()=>row));
 return Buffer.concat([Buffer.from([137,80,78,71,13,10,26,10]),chunk('IHDR',ihdr),chunk('IDAT',zlib.deflateSync(raw,{level:9})),chunk('IEND',Buffer.alloc(0))]);}
fs.writeFileSync('fixtures/img/card.png', png(1200,630,[15,118,110]));
fs.writeFileSync('fixtures/img/tiny.png', png(120,63,[180,83,9]));
<!-- fixtures/social.html, the relevant lines: a relative og:image -->
<meta property="og:image" content="/img/card.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">

<!-- fixtures/img-redirect.html -->
<meta property="og:image" content="http://127.0.0.1:8919/img/moved.png">

<!-- fixtures/img-404.html -->
<meta property="og:image" content="http://127.0.0.1:8919/img/missing.png">

<!-- fixtures/img-tiny.html -->
<meta property="og:image" content="http://127.0.0.1:8919/img/tiny.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
// img-check.mjs
const page = process.argv[2];
const html = await (await fetch(page)).text();
const tag = /<meta[^>]*(?:property|name)=["']og:image["'][^>]*>/i.exec(html);
if (!tag) { console.log('no og:image tag'); process.exit(1); }
const raw = /content=["']([^"']*)["']/i.exec(tag[0])[1];
console.log(`og:image tag value  ${raw}`);
console.log(`absolute URL?       ${/^https?:\/\//.test(raw) ? 'yes' : 'NO, a relative URL. The OG protocol requires an absolute one.'}`);
const url = new URL(raw, page).href;
let hops = 0, at = url, res;
for (;;) {
  res = await fetch(at, { redirect: 'manual' });
  if (![301, 302, 303, 307, 308].includes(res.status)) break;
  hops += 1;
  console.log(`hop ${hops}            ${res.status} -> ${new URL(res.headers.get('location'), at).href}`);
  at = new URL(res.headers.get('location'), at).href;
}
const body = Buffer.from(await res.arrayBuffer());
console.log(`fetched             ${at}`);
console.log(`status              ${res.status}`);
console.log(`content-type        ${res.headers.get('content-type')}`);
console.log(`bytes               ${body.length}`);
const dim = (b) => {
  if (b.slice(1, 4).toString() === 'PNG') return [b.readUInt32BE(16), b.readUInt32BE(20)];
  if (b.slice(0, 4).toString() === 'RIFF' && b.slice(8, 12).toString() === 'WEBP') return [(b.readUInt16LE(26) & 0x3fff) + 1, (b.readUInt16LE(28) & 0x3fff) + 1];
  if (b.slice(0, 3).toString('hex') === '474946') return [b.readUInt16LE(6), b.readUInt16LE(8)];
  if (b[0] === 0xff && b[1] === 0xd8) {
    for (let i = 2; i < b.length - 9;) {
      if (b[i] !== 0xff) { i += 1; continue; }
      const m = b[i + 1];
      if (m >= 0xc0 && m <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(m)) return [b.readUInt16BE(i + 7), b.readUInt16BE(i + 5)];
      i += 2 + b.readUInt16BE(i + 2);
    }
  }
  return null;
};
const d = dim(body);
console.log(`real dimensions     ${d ? `${d[0]} x ${d[1]}` : 'not a recognised image format'}`);
const w = /og:image:width["'][^>]*content=["'](\d+)/i.exec(html)?.[1];
const h = /og:image:height["'][^>]*content=["'](\d+)/i.exec(html)?.[1];
if (w && h && d) console.log(`declared            ${w} x ${h}  ${(+w !== d[0] || +h !== d[1]) ? '<-- does not match the file' : '(matches)'}`);

Steps

  1. Step 1.

    Start the fixture server and check the page whose og:image is a relative path.

    node img-check.mjs http://127.0.0.1:8919/social.html
    
    og:image tag value  /img/card.png
    absolute URL?       NO, a relative URL. The OG protocol requires an absolute one.
    fetched             http://127.0.0.1:8919/img/card.png
    status              200
    content-type        image/png
    bytes               3160
    real dimensions     1200 x 630
    declared            1200 x 630  (matches)

    Everything after the second line is green, and the tag is still wrong. This script knows the page URL and resolves the path against it. A consumer that reads the value out of the document has no such base.

  2. Step 2.

    Do what a consumer does with the raw value: hand it to a fetcher on its own.

    curl -sSI '/img/card.png'
    
    curl: (3) URL rejected: Malformed input to a URL function

    Exit code 3. No request is made, nothing is logged on your server, and the card is empty for a reason that leaves no trace anywhere you would look.

  3. Step 3.

    Check a page whose image URL is absolute and redirects.

    node img-check.mjs http://127.0.0.1:8919/img-redirect.html
    
    og:image tag value  http://127.0.0.1:8919/img/moved.png
    absolute URL?       yes
    hop 1            302 -> http://127.0.0.1:8919/img/card.png
    fetched             http://127.0.0.1:8919/img/card.png
    status              200
    content-type        image/png
    bytes               3160
    real dimensions     1200 x 630

    The file at the end of the hop is correct. Whether a consumer follows the hop is its own choice, so point the tag at the final URL and the question never comes up.

  4. Step 4.

    Check a page whose image is gone.

    node img-check.mjs http://127.0.0.1:8919/img-404.html
    
    og:image tag value  http://127.0.0.1:8919/img/missing.png
    absolute URL?       yes
    fetched             http://127.0.0.1:8919/img/missing.png
    status              404
    content-type        text/html
    bytes               12
    real dimensions     not a recognised image format

    Three independent signals of one defect: a 404, a text/html content type, and twelve bytes of error page. A check that only looks for a non-empty body passes this.

  5. Step 5.

    Check a page whose declared dimensions no longer describe the file.

    node img-check.mjs http://127.0.0.1:8919/img-tiny.html
    
    og:image tag value  http://127.0.0.1:8919/img/tiny.png
    absolute URL?       yes
    fetched             http://127.0.0.1:8919/img/tiny.png
    status              200
    content-type        image/png
    bytes               166
    real dimensions     120 x 63
    declared            1200 x 630  <-- does not match the file

    A 200, a correct content type, a real PNG, and the tag off by a factor of ten. The dimensions come from the PNG header, which is the only way to catch this.

  6. Step 6.

    Run the same check against a page in production, to see what a healthy result reads like.

    node img-check.mjs https://developer.mozilla.org/en-US/docs/Web/HTTP
    
    og:image tag value  https://developer.mozilla.org/mdn-social-image.46ac2375.png
    absolute URL?       yes
    fetched             https://developer.mozilla.org/mdn-social-image.46ac2375.png
    status              200
    content-type        image/png
    bytes               5978
    real dimensions     1024 x 1024
    declared            1024 x 1024  (matches)

    Absolute, no hops, 200, an image content type, and the declared size matching the header. The filename carries a content hash, which is what keeps the declared size safe: a new image gets a new URL rather than replacing the bytes behind the old one.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | absolute URL? NO | The value cannot be fetched outside the page | Write the full URL, scheme and host included | | hop 1 301 -> or 302 -> | Consumers split on whether they follow | Point the tag at the final URL | | status 404 with content-type text/html | The asset is gone and an error page answered | Fix the path, or the build step that emits it | | not a recognised image format on a 200 | The bytes are not the image the tag promises | Read the body. An HTML error page with a 200 looks like this | | does not match the file | The declared size is stale | Correct the tag, or emit both from the same source | | Everything matches | The file behind the tag is reachable and as described | Nothing here says a given platform will show it |

Common mistakes

Sign: A checking script reports the og:image as fine, and the card is empty everywhere it is shared.Cause: The script resolves a relative value against the page URL because it happens to know it. A consumer reading the tag value has no base. Step 1 and step 2 are the same path: resolved it fetches a 1200 x 630 PNG, unresolved curl exits 3 and sends no request at all.
Sign: The image URL answers 200 and the preview is still blank or shows a broken icon.Cause: A catch-all route answered with an HTML page under a 200. The status alone passes, and so does a check for a non-empty body. Read the content type and the first bytes: a PNG starts with the eight-byte signature, a JPEG with 0xFF 0xD8, and neither is recoverable from a status code.
Sign: og:image:width and og:image:height are correct in the repository and wrong in production.Cause: The asset was replaced at the same URL while the tag kept its old numbers, or the other way round. Serving the image under a content-hashed filename removes the problem, because new bytes then arrive under a new URL. Step 6 shows that pattern on a live page.

What to check next

FAQ

How do I check if the og image is working?

Fetch it and measure it, as in steps 1 to 6. Reading the tag proves only that a string is present. The status after redirects, the content type, the byte count and the dimensions from the file header tell you the image exists and is the one you meant.

How do I force a platform to update a link preview?

Change the URL. Consumers cache by the URL they fetched, so a content-hashed or versioned filename makes a re-fetch unavoidable. Some platforms also offer a manual refresh; this procedure does not test any platform's cache and cannot tell you which do.

What size should the og image be?

The Open Graph protocol sets no dimensions. Pick the size your consumers crop to, then make the tag and the file agree, which is the part step 5 checks. The two real files measured here are 1200 x 630 and 1024 x 1024.

Does og:image need og:image:alt?

The protocol recommends it whenever og:image is set. It costs one tag and is the only description of the image a consumer has.

Can I use a relative og:image if my consumer resolves it?

Some do, and the protocol does not ask them to. Step 2 shows the other case: no request leaves the client, so nothing in your access log explains the empty card.

Verified

Verified by Maks Vernynode 22.23.2curl 8.21.0

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

basic7 minpublished updated Maks Verny