invisit
All posts

By Miraç Berk13 min read

How to track outbound link clicks and file downloads: 8 tools tested

Which outbound link clicks and file downloads GA4, Plausible, Matomo, PostHog, Umami, Fathom, Simple Analytics and invisit record, tested in Chrome.

A soft 3D browser window with a link arrow leaving through its edge and a file icon dropping into a tray, in violet dithered light and shadows.

To track outbound link clicks and file downloads, turn on your analytics tool’s automatic link tracking: enhanced measurement in GA4, the Outbound links and File downloads options in Plausible, enableLinkTracking in Matomo (already in its default snippet) or the extra auto-events.js script in Simple Analytics. Umami and Fathom have no automatic option, so you add the click listener from their docs, and PostHog’s autocapture records every link click without telling downloads apart.

Automatic tracking only sees clicks on real <a href> links, and the tools disagree on everything around them. On 3 November 2026 we clicked 27 kinds of link and button on one test page with each of eight tools in headless Chrome 155. None recorded a link opened with window.open() or a link behind a /go/ redirect on your own domain as outbound, and only Matomo counted a file built in JavaScript as a download. invisit, the analytics tool we build, is one of the eight; how it decides is near the end.

Which analytics tools track outbound clicks and downloads automatically?

GA4, Plausible, Matomo, Simple Analytics and invisit record both without code. PostHog records every link click and flags the ones that leave your site. Umami and Fathom need code. As of November 2026:

Tool Outbound clicks File downloads Where you switch it Billed
GA4 Automatic: event click with outbound: true Automatic: event file_download Admin, Data streams, Enhanced measurement No
Plausible Automatic: goal Outbound Link: Click Automatic: goal File Download Site settings, Tracking; init() in the npm package Yes, as custom events
Matomo Automatic: Outlinks report Automatic: Downloads report enableLinkTracking in the snippet Matomo Cloud: one hit each
PostHog Autocapture: $autocapture with a destination URL No: a file link is an ordinary click The autocapture option, on by default Yes, every autocaptured event
Umami Manual: attributes or the script in its docs Manual Your code Umami Cloud: event plus each property
Fathom Manual: fathom.trackEvent() in a click listener Manual Your code Yes, in the pageview allowance
Simple Analytics With auto-events.js With auto-events.js data-collect on that script Paid plans only, as data points
invisit Automatic: event outbound_link Automatic: event file_download outboundLinks, fileDownloads, on by default Yes, as custom events

Sources: GA4 enhanced measurement, Plausible’s outbound links, file downloads and plans, Matomo’s tracking guide and what counts as a hit, PostHog autocapture and pricing, Umami’s outbound links guide and pricing, Fathom’s external clicks guide and pricing, Simple Analytics’ automated events and pricing, and invisit’s options and billing.

Umami’s suggested script stores the URL as a property, so each click costs two Umami Cloud events.

Most tools call a link outbound when its host differs from the page’s, which makes every subdomain outbound; GA4 and invisit are the exceptions. Only Simple Analytics records mailto: and tel: links on purpose. Downloads are a list of file extensions matched against the end of the link, and the lists differ widely:

Tool Link to another subdomain mailto: and tel: Download extensions Change the list
GA4 Internal Ignored 29 No
Plausible Outbound Ignored 26 fileExtensions replaces it
Matomo Outbound, unless in setDomains Ignored 86, plus download attributes set, add and removeDownloadExtensions
PostHog Outbound Plain clicks None Not applicable
Umami’s docs script Outbound Recorded as outbound None Not applicable
Fathom’s docs script Outbound Recorded, domain empty None Not applicable
Simple Analytics Outbound Own email_ and phone_ 7 data-extensions
invisit Internal Ignored 30 No

GA4’s documented pattern is pdf|xlsx?|docx?|txt|rtf|csv|exe|key|pp(s|t|tx)|7z|pkg|rar|gz|zip|avi|mov|mp4|mpe?g|wmv|midi?|mp3|wav|wma, with no .dmg or .apk. Plausible’s 26 add .dmg but not .apk. Simple Analytics defaults to pdf, csv, docx, xlsx, zip, doc, xls. Matomo’s 86 include .png, .jpg, .js and .xml, so a link to a full-size image is a download.

The test turned up details the docs leave out. Plausible’s tracker (npm package 0.4.7, published 28 October 2026) compares extensions case-sensitively and keeps the #fragment, so it skipped /files/Price-List.PDF?v=3 and /files/manual.pdf#page=4; GA4, Matomo, Simple Analytics and invisit counted both. A PDF on a CDN at another domain became an outbound click in Plausible, as its docs warn, a download in Matomo, Simple Analytics and invisit, and two events at once in GA4.

Which clicks did each tool record in our test?

Each row is one fresh load of a page on qa.example.com and one click, driven by Playwright in headless Chrome 155. Every tool ran its current script (Plausible as its npm package 0.4.7, Matomo 5.14.2, posthog-js 1.445.1, Simple Analytics v12) with its requests answered on our machine, so nothing reached the vendors; invisit’s went to our development collector. Umami and Fathom ran the scripts from their docs. “Outbound” and “Download” mean the tool sent that event, “Click” a generic click event, “No” nothing.

What the visitor did GA4 Plausible Matomo PostHog Umami Fathom Simple Analytics invisit
Clicked an external link Outbound Outbound Outbound Outbound Outbound Outbound Outbound Outbound
Clicked a link to docs. subdomain No Outbound Outbound Outbound Outbound Outbound Outbound No
Clicked a mailto: link No No No Click Outbound Outbound Email No
Clicked /files/report.pdf Download Download Download Click No No Download Download
Clicked Price-List.PDF?v=3 Download No Download Click No No Download Download
Clicked manual.pdf#page=4 Download No Download Click No No Download Download
Clicked a PDF on another domain Both Outbound Download Outbound Outbound Outbound Download Download
Clicked a .dmg No Download Download Click No No No Download
Clicked an .apk No No Download Click No No No Download
Clicked /export?format=csv with download No No Download Click No No No No
Clicked /go/partner (redirects) No No No Click No No No No
Middle-clicked an external link Outbound Outbound Outbound No No No No Outbound
Right-clicked an external link No No Outbound No No No No No
Pressed Enter on a focused link Outbound Outbound Outbound Outbound Outbound Outbound Outbound Outbound
Button calling window.open() No No No Click No No No No
Exported a CSV built as a Blob No No Download Click No No No No
Clicked a link rendered after load Outbound Outbound Outbound Outbound No No No Outbound

invisit’s subdomain cell comes from a rerun the same day, after invisit began treating the site’s own hosts as internal; the first run recorded it as outbound, and the rerun also sent nothing for a link to the www. host.

Every tool recorded Ctrl+click, Shift+click and target="_blank" links, and treated a button that set location.href like the window.open() row. Simple Analytics’ auto-events.js and the Umami and Fathom doc scripts go through the page’s links once, when it loads, so they missed the link our page rendered a second later, the way a single-page app renders most links (more on single-page apps).

Why are outbound click events lost when the page unloads?

A request started in a click handler is cancelled when the browser leaves the page, unless it is sent with navigator.sendBeacon() or fetch() with keepalive: true. On a fast connection the request is usually out before the next page arrives, which is why this bug passes testing on localhost and can lose clicks on slow networks.

To reproduce it, we gave an external link a click handler that sent one request, and held that request inside the browser for 300 ms before letting it out, standing in for a slow connection to the analytics host. Three runs gave the same result:

Sent with Arrived, no delay Arrived, 300 ms delay
fetch() Yes No
fetch() with keepalive: true Yes Yes
navigator.sendBeacon() Yes Yes
XMLHttpRequest Yes No
new Image().src Yes No

With the same 300 ms delay on their own requests, the outbound events of invisit, Plausible, Umami, Matomo, Fathom and Simple Analytics all arrived. GA4 and PostHog queue the click and send it in a batch as the page is hidden, beyond our delay’s reach; both arrived without it. What differed was the visitor’s wait. Umami’s tracker cancels a same-tab click on a data-umami-event link, waits for its request, then navigates, so with the delay the next page started 374 ms after the click. Matomo sends a beacon, then holds the page in a loop for up to 100 ms before it unloads (its matomo.js source): over 14 same-tab clicks its navigations started a median 169 ms after the click, against 64 to 78 ms for the other tools and 63 ms with invisit’s link tracking off.

If you write your own, send the event without touching the click:

outbound.js
// Sends one event for each click on a link that leaves the site or downloads a file.
const ENDPOINT = '/api/events' // your collector
const DOWNLOAD = /\.(pdf|zip|csv|xlsx?|docx?|pptx?|dmg|exe|msi|apk)$/i

function onLinkClick(event) {
  if (event.type === 'auxclick' && event.button !== 1) return // middle button only
  const link = event.target.closest?.('a[href]')
  if (!link || !/^https?:$/.test(link.protocol)) return // skips mailto:, tel:, blob:

  const download = DOWNLOAD.test(link.pathname)
  if (!download && link.hostname === location.hostname) return
  const body = JSON.stringify({
    name: download ? 'file_download' : 'outbound_link',
    url: link.href,
    page: location.pathname,
  })
  // keepalive lets the request outlive the page; the click is never delayed.
  fetch(ENDPOINT, { method: 'POST', body, keepalive: true }).catch(() => {})
}

for (const type of ['click', 'auxclick']) {
  document.addEventListener(type, onLinkClick, { capture: true, passive: true })
}

We ran this script through the same 27 cases: it recorded the same rows as invisit’s built-in tracking, kept the query string, and lost nothing with the 300 ms delay. Avoid the older pattern of event.preventDefault() followed by a timed redirect, which slows every click and, unless it checks for them, breaks Ctrl+click and target="_blank".

Automatic link tracking reads the href of the <a> that was clicked, so a button that calls window.open(url) or sets location.href gives it nothing to read. None of the eight tools recorded either as outbound; PostHog logged a click on the button with no destination. Send the event in the handler that opens the link:

open-demo.js
document.querySelector('#open-demo').addEventListener('click', () => {
  const url = 'https://demo.example.org/'
  window.invisit.track('outbound_link', { url })
  window.open(url, '_blank')
})

In invisit, an outbound_link event with a url property lands in the same Outbound report as the automatic ones; we checked on our development stack. In other tools, reuse the event name and property their automatic events use, so the rows end up in one report.

Do middle-clicks, Ctrl+click and right-clicks count as outbound clicks?

Ctrl+click, Cmd+click and Shift+click fire an ordinary click event, so every tool counts them. A middle-click fires auxclick instead: GA4, Plausible, Matomo and invisit listen for it, while PostHog’s autocapture, Simple Analytics and the Umami and Fathom doc scripts do not, so middle-clicked links are missing from their numbers.

Matomo goes the other way. enableLinkTracking() with no argument also listens for mousedown, mouseup and contextmenu, so a right-click on a link counts as an outlink even when the visitor only copies the address; the comment in matomo.js says as much. _paq.push(['enableLinkTracking', false]) stops that, and in our test it also stopped counting middle-clicks. Choosing “Open link in new tab” from the context menu fires no click at all, so no tool can tell it apart from a visitor who changed their mind.

A cloaked link such as /go/partner points at your own domain and redirects on the server, so no tool counts it as outbound: it is an internal link until the redirect, by which time your page is gone. Mark those links yourself. In invisit an attribute is enough, and our test recorded affiliate_click with partner: acme:

partner-link.html
<a href="/go/acme" data-invisit-event="affiliate_click" data-invisit-prop-partner="acme">
  Try Acme
</a>

Plausible documents a script for cloaked links that sends Cloaked Link: Click for any link containing /go/, and Matomo counts any link with the class matomo_link as an outlink (tracking guide).

Links through a third-party redirect, such as an affiliate network’s click tracker, do count as outbound, but to the wrapper’s domain. GA4, Plausible, Matomo, PostHog, Umami and Simple Analytics keep the full URL, so the destination and your affiliate tag survive in the query string. invisit keeps only the origin and path: https://go.skimresources.com/?id=...&url=... arrives as https://go.skimresources.com/ and an Amazon link loses its ?tag=. Fathom’s doc script keeps only the second-level name, so docs.qa.example.com and cdn.example.net both became External link clicked: example. Use an attribute or a track() call when the query matters.

How do you track downloads generated in JavaScript?

Export buttons that build a file in the browser, and download links without a file extension such as /export?format=csv, slip past every extension list. Only Matomo recorded either as a download, because it counts any link with a download attribute; it logged our Blob export as a download of blob:http://qa.example.com:8797/ab1eb79d-..., an address that means nothing in a report. Send the event where the file is made:

export.js
function downloadCsv(csv) {
  const link = document.createElement('a')
  link.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }))
  link.download = 'report.csv'
  document.body.appendChild(link)
  link.click()
  link.remove()
  window.invisit.track('file_download', { url: `${location.origin}/exports/report.csv` })
}

For a server endpoint without an extension, put data-invisit-event="file_download" and a data-invisit-prop-url on the link instead. Plausible’s docs suggest a custom event class for these, and Matomo has the matomo_download class.

Plausible, Matomo, PostHog, Simple Analytics and the Umami and Fathom doc scripts compare the link’s host with the page’s, so a link from example.com to docs.example.com counts as leaving. GA4 sent no outbound event for links from qa.example.com to docs.qa.example.com or www.qa.example.com in our test, and skips any domain you list under Configure your domains, up to 100 conditions.

In Matomo, _paq.push(['setDomains', ['*.example.com']]) marks every subdomain as your site; our test confirmed it. Plausible and Simple Analytics document no setting for this as of November 2026. invisit needs none: since November 3, 2026 it treats www and the bare domain, the same host over http://, a host above or below the page’s, and anything under one of the site’s allowed domains as internal, and we confirmed it in our cross-domain test. Sibling subdomains such as app. and docs. count as one site once the apex domain is listed.

Where do outbound clicks and downloads show up in each tool?

invisit records both by default as custom events, outbound_link and file_download, each with one property: url, the link’s origin and path with the query string and fragment removed. A link is outbound when its origin differs from the page’s, and a download when its path ends in one of 30 extensions in any letter case: pdf zip rar 7z gz tgz tar dmg exe msi pkg deb rpm apk iso csv xls xlsx doc docx ppt pptx txt rtf epub mp3 wav mp4 mov avi. Downloads are checked first and on any host, and only http: and https: links count.

The tracker listens for clicks and middle-clicks without delaying or cancelling them, and sends the event at once with fetch and keepalive. Both events are billed as custom events, and a visit with one is never a bounce (how bounce rate is counted). The Outbound tab is never shown on a public dashboard. To turn either off:

index.html
<script
  async
  src="https://api.invisit.dev/invisit.js"
  data-site="<site id>"
  data-collector="https://api.invisit.dev"
  data-outbound-links="false"
  data-file-downloads="false"
></script>

With the npm package, pass outboundLinks: false or fileDownloads: false to init(). What invisit lacks: you cannot change the extension list or keep the query string, so affiliate tags and redirect destinations need the attribute shown above.

Check your outbound tracking in five minutes

Open DevTools, tick Preserve log in the Network panel, filter by your analytics host, then: