Why images aren't loading in your Matomo heatmap (and how to fix it)

Matomo heatmap screenshots show broken-image placeholders when your CDN, signed URLs, or hot-link-protected hosts reject the fetch from Matomo's server. The click data is fine. Here's why it happens and how we deal with it.

If you've opened a Matomo heatmap and found your hero image replaced with a blank rectangle, your product photos showing as broken-image placeholders, and the click markers floating over white space, the clicks themselves are fine. Matomo recorded them at the right coordinates. What's broken is the screenshot underneath them. The images didn't come through when the Matomo server re-rendered the page, so you're left with a heatmap that looks like a wireframe of the page your visitors actually saw.

The fastest fix is a free Chrome extension we maintain called Matomo Heatmap Helper. It fetches every cross-origin image via the extension's background script (which sidesteps the in-page CORS rules) and inlines them as base64 right before each capture. The rest of this post is how to do the same thing without an extension, plus a few permanent fixes worth shipping.

How to fix it without the extension

There's a console snippet that papers over the problem right before each capture, and a handful of permanent fixes you can ship with the site or the CDN. Pick whichever fits the constraints you're working under.

List every cross-origin image reference

Before you change anything, find out which images aren't loading. Open the page in Chrome, hit F12 to open DevTools, switch to the Console, and paste this. It covers <img src>, <img srcset>, <source srcset> inside <picture>, <video poster>, SVG <use href>, and CSS background-image:

js
// Paste into the browser console. Lists every cross-origin image-like reference.
(() => {
  const out = new Set();
  const isCross = u => { try { return new URL(u, location.href).origin !== location.origin && !u.startsWith('data:') && !u.startsWith('blob:'); } catch { return false; } };
  document.querySelectorAll('img[src]').forEach(el => isCross(el.src) && out.add(el.src));
  document.querySelectorAll('img[srcset], source[srcset]').forEach(el =>
    el.getAttribute('srcset').split(',').map(s => s.trim().split(/\s+/)[0]).forEach(u => isCross(u) && out.add(u)));
  document.querySelectorAll('video[poster]').forEach(el => isCross(el.poster) && out.add(el.poster));
  document.querySelectorAll('use').forEach(el => {
    const h = el.getAttribute('href') || el.getAttributeNS('http://www.w3.org/1999/xlink', 'href');
    if (h && isCross(h)) out.add(h);
  });
  document.querySelectorAll('*').forEach(el => {
    const bg = getComputedStyle(el).backgroundImage;
    const m = bg && bg.match(/url\(['"]?([^'")]+)['"]?\)/);
    if (m && isCross(m[1])) out.add(m[1]);
  });
  console.log([...out]);
  return [...out];
})();

That gives you the exact list of URLs the Matomo renderer will have to fetch for itself. Anything served from a host you don't control is a candidate for breakage.

The quick fix: embed every image as base64 from the console

This is the snippet we paste into the browser console right before triggering Matomo's heatmap capture. It walks every image reference on the page (covering all the same selectors as the listing snippet above), fetches each unique URL once, base64-encodes it, and rewrites the references inline. By the time Matomo serializes the DOM, the images are baked in.

js
// Paste into the browser console. Embeds every cross-origin image reference
// as a base64 data URI. Works for any image host that allows CORS (most modern
// CDNs, S3 with CORS open, Unsplash, Cloudinary). Hot-link-protected hosts
// that block by Referer will fail. Fix the source there.
(async () => {
  const isCross = u => {
    try {
      if (!u || u.startsWith('data:') || u.startsWith('blob:') || u.startsWith('#')) return false;
      return new URL(u, location.href).origin !== location.origin;
    } catch { return false; }
  };
 
  // 1. Collect every cross-origin reference
  const refs = []; // {url, apply: dataUri => void}
 
  document.querySelectorAll('img[src]').forEach(el => {
    const url = el.getAttribute('src');
    if (isCross(url)) refs.push({ url, apply: d => el.setAttribute('src', d) });
  });
 
  document.querySelectorAll('img[srcset], source[srcset]').forEach(el => {
    const srcset = el.getAttribute('srcset');
    srcset.split(',').forEach(part => {
      const url = part.trim().split(/\s+/)[0];
      if (isCross(url)) {
        refs.push({ url, apply: d => {
          const cur = el.getAttribute('srcset') || '';
          el.setAttribute('srcset', cur.split(url).join(d));
        }});
      }
    });
  });
 
  document.querySelectorAll('video[poster]').forEach(el => {
    const url = el.getAttribute('poster');
    if (isCross(url)) refs.push({ url, apply: d => el.setAttribute('poster', d) });
  });
 
  document.querySelectorAll('use').forEach(el => {
    const url = el.getAttribute('href') || el.getAttributeNS('http://www.w3.org/1999/xlink', 'href');
    if (isCross(url)) {
      refs.push({ url, apply: d => {
        if (el.hasAttribute('href')) el.setAttribute('href', d);
        else el.setAttributeNS('http://www.w3.org/1999/xlink', 'href', d);
      }});
    }
  });
 
  document.querySelectorAll('*').forEach(el => {
    const bg = getComputedStyle(el).backgroundImage;
    if (!bg || bg === 'none') return;
    const m = bg.match(/url\(['"]?([^'")]+)['"]?\)/);
    if (m && isCross(m[1])) {
      const url = m[1];
      refs.push({ url, apply: d => el.style.setProperty('background-image', `url("${d}")`) });
    }
  });
 
  if (refs.length === 0) { console.warn('No cross-origin image references found.'); return; }
  console.log(`Found ${refs.length} cross-origin image references.`);
 
  // 2. Fetch each unique URL once and base64-encode
  const uniqueUrls = [...new Set(refs.map(r => r.url))];
  const dataUris = new Map();
 
  await Promise.all(uniqueUrls.map(async url => {
    try {
      const blob = await fetch(url, { mode: 'cors' }).then(r => {
        if (!r.ok) throw new Error('HTTP ' + r.status);
        return r.blob();
      });
      const dataUri = await new Promise((res, rej) => {
        const r = new FileReader();
        r.onload = () => res(r.result);
        r.onerror = rej;
        r.readAsDataURL(blob);
      });
      dataUris.set(url, dataUri);
    } catch (e) { console.warn('Image fetch failed:', url, e.message); }
  }));
 
  // 3. Apply every reference
  let applied = 0;
  for (const ref of refs) {
    const d = dataUris.get(ref.url);
    if (!d) continue;
    try { ref.apply(d); applied++; } catch (e) { console.warn('Apply failed:', ref.url, e); }
  }
 
  console.log(`Embedded ${dataUris.size}/${uniqueUrls.length} unique URLs across ${applied} references.`);
  console.log('Now trigger your Matomo heatmap capture.');
})();

Now wait untill you see something like: Embedded 8/8 unique URLs across 12 references. Now trigger your Matomo heatmap capture.

The catch: this snippet runs in the page, so it inherits the page's CORS rules. If your CDN sends Access-Control-Allow-Origin: * (or your origin), the fetch works and you're done. If it doesn't, the fetch fails for those specific URLs and the warnings tell you exactly which ones. For those, see the permanent fixes below, or skip ahead to the extension section.

Self-host or proxy through your own domain

The cleanest answer for almost everyone, same as it is for fonts. Same-origin sidesteps the entire problem because the Matomo renderer fetches the image from your domain with the same access rules as the original page. An nginx proxy is two lines:

nginx
# Edit cdn.example.com to your CDN host
location /cdn-proxy/ {
  proxy_pass https://cdn.example.com/;
  proxy_set_header Host cdn.example.com;
}

Then rewrite <img src="https://cdn.example.com/x.jpg"> to <img src="/cdn-proxy/x.jpg"> in your templates. Heavier than tweaking a CORS header, but it's bulletproof, and it incidentally gives you a layer of caching control.

Open CORS on the bucket or distribution

If self-hosting isn't worth it but you control the CDN, just open CORS. S3 wants this in the bucket's CORS configuration:

json
[{
  "AllowedOrigins": ["*"],
  "AllowedMethods": ["GET"],
  "AllowedHeaders": ["*"]
}]

Cloudfront wants a response-headers policy with Access-Control-Allow-Origin: * attached to the image behavior. Set crossorigin="anonymous" on the <img> tags too, or the browser won't include the CORS headers in the cache key and you'll get inconsistent behavior between fresh loads and cache hits.

This is also what gets the embed snippet above working from the browser console, so it's worth doing first if you can.

If the host blocks by Referer or User-Agent (common for image marketplaces, anti-scraping CDNs, and some self-hosted setups), add Matomo's screenshot fetcher to the allow list. Check Matomo's access logs from the server side to see the exact User-Agent string it sends when fetching for screenshots. Add that string, plus your Matomo server's IP, to whatever rule is rejecting the request.

This is the right fix when you don't control the host but you do control the rule. It's the wrong fix when the host is a third party doing it on purpose, like Unsplash or stock photo CDNs.

Inline above-the-fold imagery as data URIs at build time

Webpack's url-loader, Vite's ?inline query, or hand-encoded data URIs in your templates. Same idea as the console snippet but baked into the build, so every page ships with the hero image already embedded and the Matomo renderer doesn't need to fetch anything cross-origin to draw it.

Worth doing for the handful of images that matter most for screenshots (hero, product photos, anything above the fold). Not worth doing for every thumbnail on a category page.

Watch for signed-URL expiry

Different problem, same symptom. If you serve images from S3 or Cloudfront with pre-signed URLs (TTL of an hour, a day), the signature will be stale by the time Matomo's renderer fires. The image was loadable when the visitor saw it. It isn't anymore.

Two ways out: extend the TTL past whatever your heatmap capture window is (Matomo can lag by hours or days), or proxy through your own domain and let the proxy sign on the fly. The proxy fix doubles as the same-origin fix above.

Why Matomo can't load your images

Same root cause as the font version of this problem. Matomo's screenshot capture is a two-step process. First, the tracker serializes your live DOM into HTML and ships it off. Then, your Matomo server re-renders that HTML to produce the screenshot you see in the heatmap view. No browser session, no cookies, no Referer pointing at your domain. Just an HTML file being rendered cold from a different IP.

Anything in that HTML that points outward to a third-party host has to make it past the third party first. For images, that's usually where it stops.

What's actually failing

A few patterns we keep running into:

  • Hot-link protection on the CDN, the asset host, or a self-hosted nginx config rejects requests with the wrong Referer or no Referer at all. Matomo's server doesn't send your domain as Referer when it's fetching for a screenshot, so the response is a 403 and the image renders as a placeholder.
  • Pre-signed URLs from S3 or Cloudfront have already expired by the time Matomo gets around to the screenshot. The visitor saw a valid image, the renderer sees an AccessDenied XML body that no image format knows what to do with.
  • CDNs that serve different content (or 403s) based on the Origin header, often anti-scraping setups or geo-restricted distributions, reject Matomo's IP outright.
  • Rate-limited or IP-reputation-blocked image hosts. Unsplash, some Cloudinary tiers, anything that gates by request volume per IP. Matomo's renderer batches requests, the host throttles, the screenshot ships missing half its imagery.
  • <picture> <source srcset> references that resolve to a different cross-origin URL than the <img src> fallback. The <img> might come through, the higher-res <source> doesn't, and which one Matomo picks depends on its renderer's viewport.

If your heatmap is showing broken-image placeholders, you're hitting at least one of these. Often more than one on the same page.

This is also the same family of problem that breaks fonts, scroll containers, and sticky headers in the same screenshot. We've written a longer post on broken Matomo heatmap screenshots that walks through the rest, and a separate post on why fonts aren't loading in your Matomo heatmap since that one comes up almost as often.

What we'd actually do

If you control the CDN or the host, open CORS and set crossorigin="anonymous" on the <img> tags. The console snippet starts working immediately and a permanent fix is one CDN config away.

If you control the templates but not the CDN, proxy the images through your own domain. Same-origin solves it once and for all and removes a class of breakage you'd otherwise keep tripping over.

If neither is reachable (third-party hosts, locked-down buckets, signed URLs you don't own), the Matomo Heatmap Helper Chrome extension is what we use on client sites where we can't change the stack. Its background script fetches the images outside the page's CORS context, so it works even on hosts that block the in-page snippet on Referer or Origin. Free, open source, code on GitHub.

Martez is the larger project the extension came out of. It connects Matomo with Meta Ads and Google Ads so ROAS, CLV, and attribution sit next to your web analytics instead of in a separate spreadsheet. It's in private beta. Join the waitlist if that's relevant to you.

Most of the time, this is a CDN config away. Worth doing once.