I crawled the home pages of 114 sites that all belong to the same small publishing network — prediction-market and crypto-ticker properties, mostly Next.js and mostly Cloudflare. Every one answered HTTP 200. Between them, the HTML contains 727 <img> elements.
104 of those 114 pages contain none of them.
That number surprised me enough to re-run the crawl three different ways before I believed it: parsing <img> with a regex, checking for lazy-loading attributes and srcset, and then separately fetching every linked stylesheet to see whether the images were hiding in CSS instead. They mostly weren't. The median page is 60,452 characters of HTML with no image reference anywhere in it.
Here is the counting code, if you want to point it at your own site:
import re, urllib.parse IMG_RE = re.compile(r"<img\b([^>]*)>", re.I | re.S) ATTR_RE = re.compile(r"""([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))""") def attrs_of(raw): d = {} for m in ATTR_RE.finditer(raw): v = m.group(3) if m.group(3) is not None else m.group(4) or m.group(5) d.setdefault(m.group(1).lower(), v) return d def imgs(html, base): out = [] for m in IMG_RE.finditer(html): a = attrs_of(m.group(1)) src = a.get("src") or a.get("data-src") or "" if not src: continue out.append({ "src": urllib.parse.urljoin(base, src), "w": a.get("width"), "h": a.get("height"), "loading": (a.get("loading") or "").lower(), "srcset": bool(a.get("srcset")), }) return out 104 pages, zero images
Of the 114 pages, 104 returned an empty list. The remaining ten split in a lopsided way: six pages carry ten <img> tags between them, and four pages carry 717.
I then asked a harder question: forget <img>, does the page reference any image at all? So I added three more sources — url() inside inline style attributes, url() inside <style> blocks, and url() inside the linked stylesheets (192 sheets fetched, 188 OK) — plus a count of inline <svg> elements.
The result:
- 37 pages reference no image in any of those five places. No
<img>, no CSS background, no inline SVG. Just text, a stylesheet, and a script tag. - 43 pages have no
<img>and no CSS image, but do carry inline SVG — a median of 26 icons each, drawn as paths. - 24 pages do pull images from CSS, 26 files in total across the whole network.
- 52 pages use inline SVG somewhere.
So the "no images" story is really an "images were replaced by icon paths" story. Forty-three pages are drawing their visual furniture as <svg> rather than shipping files. That is a defensible choice for icons: one request fewer, no extra format negotiation, crisp at any size.
It stops working the moment you want a photograph.
The four pages that do ship <img>
The four are two templates cloned once each — a coinatlas* pair and a coinwiki* pair. They are also the only pages where you can measure what image delivery looks like when someone actually attempts it:
| what I checked | result |
|---|---|
<img> tags on the four pages | 717 (98.6% of all tags in the network) |
| images served from a third-party CDN | 717 (all coin-images.coingecko.com) |
loading="lazy" | 722 of 727 (99.3%) |
no loading attribute at all | 5 |
loading="eager" | 0 |
fetchpriority on any image | 0 of 727 |
width and height both present | 7 of 727 (1.0%) |
srcset | 3 of 727 (0.4%) |
.png / .jpg / .webp | 80.5% / 18.0% / 0.8% |
image displayed in a 28 px circle (h-7 w-7) | 202 |
Seven hundred of those images are coin logos pulled from CoinGecko's large bucket, which I fetched and measured at 250×250 and 12,242 bytes for BTC, rendered inside a 28 px round element. The 202 carrying the literal class h-7 w-7 rounded-full are the visible subset on the home page; the rest sit in coin tables further down.
Every single one is lazy. Not one declares its intrinsic size. That combination is the classic layout-shift recipe: a 28 px box that reserves no space until a 250 px PNG arrives and the browser relayouts around it. It also means every one of those 250×250 files is decoded and scaled down to a 28 px slot — about 80× the pixels actually painted, on 700 images.
The srcset story is worse than the percentage suggests. Three images have it. All 717 point at an external host, so the network controls neither the variant set nor the cache headers, and has no fallback if that path changes. If CoinGecko reshapes those URLs again, 717 images break at once — in the same deploy, on four sites, with no local copy to fall back to. Pages like the Aave listing on one of those templates would be the first place you'd see it: 13 images, every one of them rented.
The only image a crawler ever fetches
If 104 pages have no <img>, what image does anyone ever see? The answer is og:image — the link-preview card. That is the whole image surface of this network.
40 pages declare one. 74 don't. And this is where the numbers get genuinely bad, because I downloaded all 40 and read the pixel dimensions out of the file headers (PNG IHDR at byte 16, JPEG SOFn after the marker scan):
import struct def dims(b): if b[:8] == b"\x89PNG\r\n\x1a\n" and len(b) >= 24: w, h = struct.unpack(">II", b[16:24]) return w, h if b[:3] == b"\xff\xd8\xff": i = 2 while i < len(b) - 9: marker = b[i + 1] if marker in (0xC0, 0xC1, 0xC2, 0xC3): h, w = struct.unpack(">HH", b[i + 5:i + 9]) return w, h if marker in (0xD8, 0xD9) or 0xD0 <= marker <= 0xD7: i += 2 continue i += 2 + struct.unpack(">H", b[i + 2:i + 4])[0] return None, None Of the 40 declared cards:
- 36 resolve, 4 don't: two return 404, two return 403 to a plain GET (the files may exist behind bot protection — either way a preview fetcher sees nothing).
- 15.65 MB total. Median 270 KB. Largest 1.34 MB.
- 18 of 36 are over 300 KB, 12 are over 500 KB.
- 28 PNG, 7 JPEG, 1 SVG.
- 19 are ~1.91:1 (the shape every platform wants), 11 are 1:1 squares, 4 are ~3:2, 2 are other.
- One is 48×48 — a favicon being used as a social card.
A 48×48 PNG declared as your link preview is the same category of mistake as loading="lazy" on your hero image: it works in the sense that no error is thrown, and it is wrong in every way that matters.
10 of 20 cards are lying about their own size
22 pages also declare og:image:width and og:image:height. Of the 20 whose values parse as integers, 10 disagree with the actual file. Every one of the 10 declares 1200×630:
| page | declared | actual |
|---|---|---|
| marketoraclehub.com | 1200×630 | 1731×909 |
| marketpredictionlab.com | 1200×630 | 1024×1024 |
| openaistockforecast.com | 1200×630 | 1024×1024 |
| spacexstocktarget.com | 1200×630 | 1024×1024 |
| predictmarketcn.com | 1200×630 | 1200×640 |
The pattern is obvious once you see it: someone copied the canonical 1200×630 snippet into a shared <head> component, then generated a square 1024×1024 image with a different tool. The metadata was never regenerated. Ten pages, five of them asserting a dimension the file does not have.
This isn't cosmetic. Platforms that read og:image:width/height use them to reserve the card's box before the image arrives and to decide cropping; when the values are wrong, you get a card that either letterboxes or crops the interesting part off. And the ones that ignore the declared values and re-read the file get the square version, which in a feed becomes a center crop of a 1024×1024 composition — usually through the title text.
One of the square-card pages is a good example: the card is a designed square with the logo centered, and every platform that consumes it will crop it to 1.91:1 and slice the top and bottom off.
The weight is the part I'd fix first
Median 270 KB for an image whose only job is to sit in a 600×314 preview box. A 1200×630 card at quality 80 is usually somewhere between 60 and 90 KB as JPEG and well under that as WebP, so this network is paying three to four times the going rate.
Aggregate: 15.65 MB across 36 files, for a network whose entire HTML payload — 114 pages, median 60,452 characters — adds up to about 6.9 million characters. The social cards weigh more than twice the site.
And there is a neat irony in where the heaviest one lives. The biggest card, 1.34 MB, belongs to a page about SpaceX's valuation — a 178,012-character HTML document that contains zero <img> tags, paired with a 1.34 MB PNG that only a crawler will ever download.
Why I think this matters
Three reasons, in order of how much I'd care:
1. Image search is a separate index. Pages with no <img> and no CSS background contribute nothing to it. For a network publishing "what is a prediction market" explainers, that's a whole query surface left on the table — and it's the one surface where a small site can still rank on day one.
2. Every share renders as a random crop or a blank box. 4 of 40 cards are unreachable, 11 are square, 10 declare the wrong size, one is a 48×48 favicon. When someone pastes a link into Slack or X, the card is the first impression, and for a large share of this network the first impression is a cropped square or nothing.
3. The pages that do use images use them in the worst available way. 717 <img> tags, 99.3% lazy, 1% with intrinsic dimensions, 0.8% WebP, all of them from a third-party host whose paths the network doesn't control. That is not a small inefficiency; it's a layout-stability and availability dependency disguised as a design decision.
Reproduce it on your own site
This is the whole check, and it takes about 40 lines. It tells you whether your social card exists, how heavy it is, and whether your declared dimensions match the file:
def audit(url): html = fetch(url) m = (re.search(r'property=["\']og:image["\'][^>]*content=["\']([^"\']+)', html) or re.search(r'content=["\']([^"\']+)["\'][^>]*property=["\']og:image["\']', html)) if not m: return {"og_image": None} body = fetch(m.group(1), binary=True) w, h = dims(body) declared = re.search(r'og:image:width["\'][^>]*content=["\'](\d+)', html) return { "bytes": len(body), "dims": (w, h), "ratio": round(w / h, 2) if w and h else None, "declared_width": int(declared.group(1)) if declared else None, "ok": bool(w and declared and int(declared.group(1)) == w), } Run it on your own home page before you run it on anyone else's. The ok flag is the one that earns its keep: it's the only check here that compares what you claim with what you actually shipped.
What I'd do, in this order
- Generate the card at 1200×630, export WebP with a JPEG fallback, target under 100 KB. One template, one output.
- Delete
og:image:widthandog:image:heightfrom the shared<head>unless they are generated from the same source as the image. Wrong metadata is worse than absent metadata. - Add
widthandheightto every<img>. For the 28 px coin logos that'swidth="28" height="28"and asrcsetwith a 56 px variant — the current 250 px source is 8× the pixels needed. - Stop lazy-loading anything above the fold, and put
fetchpriority="high"on the one image that is the LCP candidate. Zero of the 727 images here declare a priority, so the browser discovers them all at the same, late, moment. - For the 37 pages with no image at all: pick one page, add one real photograph with
width,height,srcsetand meaningfulalt, and watch the image-search impressions for a month. That's the experiment the rest of this network is missing.
The thing I keep coming back to is that none of this is exotic. These are five attributes and one image pipeline. The network has 114 pages, 60 KB of HTML each, and a well-built static generator behind it — the infrastructure is fine. What's missing is that nobody ever opened the network tab on a share card.
