If you've opened a Matomo heatmap and found your hero set in Times New Roman, your buttons set in Arial, and the click markers floating a couple of pixels off the elements they belong to, the clicks themselves are fine. Matomo recorded them at the right coordinates. What's broken is the screenshot underneath them. Your brand fonts didn't load when the Matomo server re-rendered the page, so it fell back to whatever system fonts it had, and a different font means different metrics, which means the layout shifts.
The fastest fix is a free Chrome extension we maintain called Matomo Heatmap Helper. It embeds every font on the page as base64 right before each heatmap capture, no stylesheet changes needed. The rest of this post is how to do the same thing without an extension, and (further down) why Matomo can't load your fonts in the first place.
How to fix it without the extension
There's a quick console snippet that papers over the problem right before each capture, and a few permanent fixes you can ship with the site. Pick whichever fits your stack. There's also a JS pattern at the end that breaks the screenshot regardless of which fix you ship, worth knowing about either way.
Diagnose what's actually failing
Before you change anything, find out which fonts aren't loading. Open the page in Chrome, hit F12 to open DevTools, switch to the Console, and paste:
// Lists every font on the page and whether it loaded
document.fonts.ready.then(() => {
[...document.fonts].forEach(f =>
console.log(`${f.family} ${f.weight} ${f.style} → ${f.status}`));
});Anything with a status that isn't loaded is a candidate. Then DevTools → Network → filter by "Font" and watch for 403 or CORS errors on your @font-face URLs. That tells you whether you're looking at a CDN problem, an Adobe Fonts problem, or a serialization problem.
The quick fix: embed every font 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 @font-face rule on the page (inline, same-origin, and cross-origin stylesheets), fetches the font binaries, base64-encodes them, and injects a fresh <style> tag with self-contained data URIs. By the time Matomo serializes the DOM, the fonts are baked in.
// Paste into the browser console. Embeds every @font-face on the page as base64.
(async () => {
const fontFaces = [];
const parseSrc = (srcValue, baseUrl) => {
const out = [];
const re = /url\(\s*['"]?([^'")\s]+)['"]?\s*\)(?:\s*format\(\s*['"]?([^'")\s]+)['"]?\s*\))?/gi;
let m;
while ((m = re.exec(srcValue)) !== null) {
const raw = m[1], format = m[2];
if (raw.startsWith('data:')) { out.push({ url: raw, format }); continue; }
try { out.push({ url: new URL(raw, baseUrl).href, format }); } catch {}
}
return out;
};
const collect = (rule, baseUrl) => {
if (rule instanceof CSSFontFaceRule) {
const s = rule.style;
const family = s.getPropertyValue('font-family').replace(/['"]/g, '').trim();
const src = s.getPropertyValue('src');
if (!family || !src) return;
fontFaces.push({
family,
weight: s.getPropertyValue('font-weight') || '',
style: s.getPropertyValue('font-style') || '',
display: s.getPropertyValue('font-display') || '',
unicodeRange: s.getPropertyValue('unicode-range') || '',
src: parseSrc(src, baseUrl),
});
} else if (rule.cssRules) {
for (const r of Array.from(rule.cssRules)) collect(r, baseUrl);
}
};
// 1. Walk accessible stylesheets via the CSSOM
const crossOriginUrls = [];
for (const sheet of Array.from(document.styleSheets)) {
const baseUrl = sheet.href || location.href;
try {
for (const rule of Array.from(sheet.cssRules)) collect(rule, baseUrl);
} catch {
if (sheet.href) crossOriginUrls.push(sheet.href);
}
}
// 2. Re-fetch cross-origin stylesheets as text and regex-parse @font-face blocks
const parseCssText = (txt, baseUrl) => {
const re = /@font-face\s*\{/gi;
let m;
while ((m = re.exec(txt)) !== null) {
const start = m.index + m[0].length;
let depth = 1, i = start;
while (depth > 0 && i < txt.length) {
if (txt[i] === '{') depth++; else if (txt[i] === '}') depth--;
i++;
}
if (depth !== 0) continue;
const body = txt.substring(start, i - 1);
const get = n => {
const r = new RegExp(n + '\\s*:\\s*([^;]+)', 'i').exec(body);
return r ? r[1].trim() : '';
};
const family = get('font-family').replace(/['"]/g, '').trim();
const src = get('src');
if (!family || !src) continue;
fontFaces.push({
family,
weight: get('font-weight'),
style: get('font-style'),
display: get('font-display'),
unicodeRange: get('unicode-range'),
src: parseSrc(src, baseUrl),
});
}
// Recurse into @import
const importRe = /@import\s+(?:url\(\s*['"]?([^'")\s]+)['"]?\s*\)|['"]([^'"]+)['"])/gi;
const imports = [];
while ((m = importRe.exec(txt)) !== null) {
const u = m[1] || m[2];
try { imports.push(new URL(u, baseUrl).href); } catch {}
}
return imports;
};
const visited = new Set();
const fetchCss = async (url) => {
if (visited.has(url)) return;
visited.add(url);
try {
const txt = await fetch(url).then(r => r.text());
const imports = parseCssText(txt, url);
for (const u of imports) await fetchCss(u);
} catch (e) { console.warn('CSS fetch failed:', url, e.message); }
};
for (const url of crossOriginUrls) await fetchCss(url);
if (fontFaces.length === 0) { console.warn('No @font-face rules found.'); return; }
console.log(`Found ${fontFaces.length} @font-face rules.`);
// 3. Fetch each unique font binary and base64-encode it
const uniqueUrls = [...new Set(
fontFaces.flatMap(f => f.src.filter(s => !s.url.startsWith('data:')).map(s => s.url))
)];
console.log(`Fetching ${uniqueUrls.length} font files...`);
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('Font fetch failed:', url, e.message); }
}));
// 4. Build new @font-face CSS using data URIs (with original URLs as fallback)
const css = fontFaces.map(f => {
const srcParts = f.src.map(s => {
const u = dataUris.get(s.url) || s.url;
return s.format ? `url("${u}") format("${s.format}")` : `url("${u}")`;
});
if (srcParts.length === 0) return '';
const props = [`font-family: "${f.family}"`, `src: ${srcParts.join(', ')}`];
if (f.weight) props.push(`font-weight: ${f.weight}`);
if (f.style) props.push(`font-style: ${f.style}`);
if (f.display) props.push(`font-display: ${f.display}`);
if (f.unicodeRange) props.push(`unicode-range: ${f.unicodeRange}`);
return `@font-face {\n ${props.join(';\n ')};\n}`;
}).filter(Boolean).join('\n\n');
// 5. Inject into <head> so Matomo's DOM serializer sees self-contained fonts
const style = document.createElement('style');
style.setAttribute('data-heatmap-font-fix', 'true');
style.textContent = css;
document.head.appendChild(style);
console.log(`Embedded ${dataUris.size}/${uniqueUrls.length} fonts across ${fontFaces.length} @font-face rules.`);
console.log('Now trigger your Matomo heatmap capture.');
})();To confirm the injection actually landed before you take the screenshot:
// Confirms the injected styles are in <head>
const tag = document.querySelector('style[data-heatmap-font-fix]');
console.log(tag
? `OK, injected ${(tag.textContent.match(/@font-face/g) || []).length} @font-face rules, `
+ `${(tag.textContent.match(/data:font/g) || []).length} use base64 data URIs.`
: 'No injected style tag found, re-run the embed snippet.');That's the quick path. It works for one-off captures and the review cycles where you need a clean screenshot now and don't want to wait on a code change. For anything you'll re-capture regularly, ship one of the permanent fixes below.
Self-host the fonts (the canonical fix)
The cleanest answer for almost everyone. Download the woff2 and woff variants you actually use from google-webfonts-helper, drop them in /public/fonts, and ship CSS like this:
/* Edit the family name and file paths to match your downloaded files */
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/fonts/inter-400.woff2") format("woff2"),
url("/fonts/inter-400.woff") format("woff");
}Now the fonts come from your own domain. Same origin as the page, no CORS to negotiate, the Matomo server fetches them happily, and your screenshots render in the right typeface. It's also faster for your real users, because you've removed a DNS lookup and a third-party connection from every page load. Most of the time it's a couple of hours of work and a small CSS diff.
Fix CORS on your CDN
If you'd rather keep fonts on a CDN, configure the CDN to return Access-Control-Allow-Origin: * on font responses, and add crossorigin on the preload link:
<!-- Edit the URL to your CDN's font path -->
<link rel="preload" as="font" type="font/woff2"
href="https://your-cdn.example.com/inter.woff2"
crossorigin="anonymous">This works for Cloudflare, Fastly, AWS, anything you control. It does not work for Adobe Fonts. The domain allow-list there is enforced server-side and you can't change it. If you're on Typekit, self-hosting is the only path. Adobe's licensing on self-hosting is its own conversation.
Bake the embed into your build
If you don't control the stylesheet but you do control the build, run the same logic the console snippet runs, but at build time. Spin up a headless browser (Playwright or Puppeteer), execute the embed code against your built page, capture the resulting <style> block, and write it into your HTML template. The page ships with fonts already embedded. No runtime fetch, no console paste before each capture.
This is the heaviest of the three permanent fixes. It also covers the case where the marketing site is on a CMS that won't let you touch the stylesheet but does let you ship a build script.
The serialization gotcha
One more thing that catches people. Fonts loaded via JS at runtime don't survive Matomo's DOM serialization:
// BROKEN, won't survive Matomo's serialization
const f = new FontFace("Inter", "url(/fonts/inter.woff2)");
await f.load();
document.fonts.add(f);The font is registered with the browser's document.fonts set, but it isn't in the HTML the serializer reads. The screenshot will fall back to system defaults even if the font is technically "loaded" in your browser. Same goes for fonts injected by JS frameworks at hydration time. Use a <style> tag with @font-face rules, or a regular <link rel="stylesheet">. Anything the serializer can see in static HTML is fine. document.fonts.add() is invisible to it.
Why Matomo can't load your fonts
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 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 @font-face declarations, that's where it stops.
What's actually failing
A few patterns we keep running into:
- The CDN serving your fonts doesn't return
Access-Control-Allow-Originheaders. The browser tolerates the same-origin case without them. Matomo's server, fetching from a different origin, doesn't. - Adobe Fonts (Typekit) validates the
OriginandRefererheaders against the domains you've allow-listed in your account. Matomo's server isn't on that list, ever, and Typekit responds with 403. There's no way to add Matomo's IP either. fonts.gstatic.comand a few other CDNs rate-limit or outright block server-side renderers, sometimes by user-agent, sometimes by IP reputation. The fonts work in your browser and not in Matomo's renderer.- Fonts loaded with
new FontFace(...).load()thendocument.fonts.add(...)aren't in the serialized HTML at all. The serializer reads the DOM, not the runtime FontFace set.
If your heatmap screenshot is showing the wrong font, you're hitting at least one of these. Often more than one.
This is also the same family of problem that breaks images, 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.
What we'd actually do
If you control the stylesheet, self-host. It fixes the heatmap, removes a third-party dependency, and makes your real users' page faster too.
If you can't, embed the fonts as base64 data URIs in your existing stylesheet. Same effect on Matomo's renderer, no infrastructure changes.
If neither is reachable, the Matomo Heatmap Helper Chrome extension is what we use on client sites where we can't change the stack. It runs the same embed logic as the snippet above, plus equivalent fixes for images, scroll containers, and sticky headers, on every capture. 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.
The fonts are usually fixable in a stylesheet. Worth doing once.