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

Matomo heatmap screenshots show broken-image placeholders when the image URLs in the captured snapshot won't resolve at view time: expired signed URLs, hot-link protection, or a CDN that blocks the request. 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 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 picture underneath them: the images didn't load when the heatmap was drawn, so you're left with a wireframe of the page your visitors actually saw.

The reason matters for the fix, so it's worth getting it right up front. Matomo doesn't store your images. When the tracker captures a heatmap, it saves the page's HTML structure, not the assets. The images and CSS load straight from your website later, when you open the heatmap to view it. That's documented behaviour: "The CSS and images, however, are not stored by Matomo. They are still loaded directly from your website when you view the heatmap." So a broken image means a URL in the saved snapshot that no longer resolves the way it did when the visitor was on the page.

TL;DR

The click data is accurate; only the picture is missing. Matomo saves the page's HTML but not its images, so each image URL in the snapshot has to load again when you open the heatmap. A broken one is a URL that no longer resolves: an expired signed link, hot-link protection, or a CDN that blocks the request. Which fix you need depends on which of those you're hitting. Open CORS if you control the host, proxy the images through your own domain if you control the templates, or inline them before capture. Whichever you pick, retake the screenshot afterward, or the heatmap keeps the old broken one.

  1. 1

    Visitor's browser

    tracker saves the DOM snapshot

  2. 2

    Matomo stores HTML + click data

    the images themselves are not stored

  3. 3

    You open the heatmap

  4. 4

    Your browser re-fetches each image URL

    from your site or CDN

URL still resolves

Heatmap looks right

expired, blocked, gone

Broken-image placeholders

Matomo saves the HTML, not the pictures. The image URLs have to resolve again at view time. That's where they break.

The fastest fix is a free Chrome extension we maintain called Matomo Heatmap Helper. It fetches cross-origin images via the extension's background script (which sidesteps the in-page CORS rules) and inlines them as base64 right before each capture, so the bytes are baked into the snapshot. 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 bakes the images into the snapshot 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. Either way, you'll need to retake the screenshot afterwards. That part trips people up, so it's covered first.

Retake the screenshot (the step that's easy to miss)

Applies toMatomo HSR 5.1+On-Premise & Cloud

Embedding the images does nothing to a heatmap that already has a bad screenshot stored against it. You have to get Matomo to capture again.

The simplest route: delete the existing screenshot from the heatmap and let the next visit recapture it. If you'd rather not wait for a visit, Heatmap & Session Recording 5.1.0 and later add a "Capture Heatmap Snapshot Manually" option in the heatmap's settings. Turn that on, load the page yourself, run your fix (the snippet below, say), and then trigger the capture from the console:

js
_paq.push(['HeatmapSessionRecording::captureInitialDom', { idHeatmap: 1 }]);

Swap in your heatmap's ID. One catch: if your own IP is in Matomo's exclusion list, the capture is skipped and nothing happens, so check that first if the snapshot doesn't update.

List the cross-origin images first

Before changing anything, find out which images are at risk. Open the page in Chrome, hit F12 for DevTools, switch to the Console, and paste this. It covers <img src>, <img srcset>, <source srcset> inside <picture>, <video poster>, and CSS background-image:

js
// Paste into the browser console. Lists cross-origin image-like references
// found in the common HTML/CSS spots. Not exhaustive; it won't catch
// pseudo-element backgrounds, mask-image, or border-image.
(() => {
  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('*').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 cross-origin URLs the saved snapshot will depend on when someone views the heatmap. Anything served from a host you don't control is a candidate for breakage. To confirm which ones actually fail, open the heatmap and watch the Network tab: the failing image requests show up there with their status and the reason.

The quick fix: embed the images as base64 from the console

This is the snippet we paste into the console right before triggering a capture. It walks the same selectors as the listing snippet, fetches each unique URL once, base64-encodes it, and rewrites the reference inline. By the time Matomo saves the DOM, the image bytes are part of the HTML, so viewing the heatmap doesn't depend on the external URL anymore.

js
// Paste into the browser console. Embeds cross-origin image references as
// base64 data URIs. Works for any image host that allows CORS (most modern
// CDNs, S3 with CORS open, Unsplash, Cloudinary). Hosts that block by Referer
// or don't send Access-Control-Allow-Origin 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 cross-origin references
  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('*').forEach(el => {
    const bg = getComputedStyle(el).backgroundImage;
    if (!bg || bg === 'none') return;
    const m = bg.match(/url\(['"]?([^'")]+)['"]?\)/);
    // Note: this replaces the whole background-image. An element with a
    // gradient plus an image, or several layered backgrounds, needs manual
    // handling; only the first url() is embedded.
    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 (see the recapture step above).');
})();

It skips SVG <use> references on purpose. External sprites point at a fragment like /sprite.svg#icon, and swapping that for a whole fetched file as a data URI doesn't reliably reference the right symbol. If your icons come from an external sprite, inline the sprite into the page yourself and point <use href="#symbol-id"> at the local copy.

Wait for the console to log something like:

text
Embedded 8/8 unique URLs across 12 references.
Now trigger your Matomo heatmap capture (see the recapture step above).

The catch: this snippet runs in the page, so it inherits the page's CORS rules. fetch(url, { mode: 'cors' }) only succeeds if the image response carries Access-Control-Allow-Origin for your origin (or *). When 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 whole problem, because the image then loads from your domain under the same rules as the page itself. A minimal nginx proxy:

nginx
# Replace cdn.example.com with your CDN host.
location /cdn-proxy/ {
  proxy_pass https://cdn.example.com/;
  proxy_set_header Host cdn.example.com;
  proxy_ssl_server_name on;  # many CDN origins need SNI on the upstream TLS handshake
}

Then rewrite <img src="https://cdn.example.com/x.jpg"> to <img src="/cdn-proxy/x.jpg"> in your templates. It's heavier than tweaking a CORS header, but it removes the cross-origin dependency entirely as long as your proxy can legally and reliably fetch the asset, and it gives you a layer of caching control for free.

Open CORS on the bucket or distribution

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

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

CloudFront wants a response-headers policy with Access-Control-Allow-Origin attached to the image behavior. That one header is what makes the console snippet's fetch succeed, so do it first if you can. (Matomo's own crossorigin guidance is a separate thing: it's about adding crossorigin="anonymous" to your <link rel="stylesheet"> tags so Matomo can read and save your CSS. It isn't what gets the image snippet working.)

Allow-list whatever request actually reaches the host

If a host blocks by Referer or User-Agent (common for image marketplaces and anti-scraping CDNs), you need to know what the blocked request looks like before you can let it through. Don't assume a "Matomo screenshot bot." Open the heatmap, watch the Network tab for the failing image, and check your host or CDN access logs for that request. Note its Referer, Origin, User-Agent, and IP, then loosen whatever rule is rejecting it. When the heatmap loads in your browser, the request to the image carries your Matomo domain (or nothing) as the Referer rather than the original page, which is usually what trips a Referer-based rule.

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 blocking on purpose, like Unsplash or a stock-photo CDN. There you self-host, proxy, or inline instead.

Inline above-the-fold imagery 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 snapshot never references it externally.

Worth doing for the handful of images that matter most for screenshots: the hero, key 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 snapshot stores that signed URL, and by the time you open the heatmap to view it, the signature may be stale. The image was loadable when the visitor saw it. It isn't anymore, so you get a placeholder.

Two ways out: extend the TTL past the gap between capture and when you'll view the heatmap, or proxy through your own domain and sign on the fly. The proxy route doubles as the same-origin fix above.

What's actually failing

Once you accept that the image loads at view time from a URL the snapshot saved, the failure modes sort themselves out. A few we keep running into:

SymptomHow to confirmLikely causeDurable fix
Loads on the live page, broken in the heatmap right after captureOpen the heatmap, check the Network tab for the failed request's status and headersReferer/Origin-based hot-link protection rejecting the view-time requestOpen CORS, proxy same-origin, or inline before capture
Worked at capture, broken when you view it days laterOpen the stored image URL in a tab; look for an AccessDenied/expired-signature responseSigned S3/CloudFront URL expiredLonger TTL, sign-on-demand proxy, or inline before capture
<img> shows but a higher-res <picture><source> doesn'tCompare the two URLs in the snapshotThe <source> resolves to a different cross-origin host with stricter rulesApply the same CORS/proxy fix to every source
A whole third-party host is blankCheck the response status in NetworkHost blocks non-origin requests on purposeSelf-host, proxy, or inline; you can't force a third party
Snippet logs "Image fetch failed" for some URLsRead the console warningsThose hosts don't send Access-Control-Allow-OriginPermanent CORS/proxy fix for those hosts

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 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 one on why fonts aren't loading in your Matomo heatmap, since that comes up almost as often.

What we'd actually do

If you control the CDN or the host, open CORS. The console snippet starts working immediately and a permanent fix is one config change away.

If you control the templates but not the CDN, proxy the images through your own domain. Same-origin solves it for good 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.

One honest caveat to close on: not every version of this is a CORS toggle. Expired signed URLs, Referer-based hot-link blocks, and genuine third-party hosts each need their own fix from the table above. Figure out which row you're in first, then apply the one that matches, and retake the screenshot when you're done.