How Notion Embed Tracking Works
Updated May 2026 · 7 minute read · Technical
Every Notion analytics tool — including PageInsight — uses an iframe embed instead of a script tag. This is not a workaround or a hack. It's the only technically viable mechanism for running tracking code inside a Notion-published page. This article explains why, how the tracking pipeline works end-to-end, and what limitations to understand before relying on the data.
In this guide
Why Notion strips script tags
When you publish a Notion page to the web, Notion's servers render the page's block tree as HTML served under notion.so. Crucially, Notion controls every byte of this HTML — there is no template you can override, no theme system that lets you add markup, no plugin architecture.
This is by design and by necessity. notion.so is a shared-tenancy platform — thousands of different Notion pages are served from the same origin. If any page author could inject arbitrary JavaScript into a notion.so page, they could run code in the security context of every other Notion user who visited that page: reading cookies, exfiltrating session tokens, redirecting to phishing pages. It would be a trivially exploitable XSS vector. So Notion sanitizes all page content before rendering, stripping <script> tags and any other executable HTML. This is standard practice for every user-generated-content platform.
The same applies to Code blocks in Notion. A code block renders its content as styled preformatted text — a <pre> element, not an executable script. Even if you paste a full Google Analytics snippet into a Code block and set the language to JavaScript, the Notion renderer emits it as text, not as executable code.
The /embed block as a code execution surface
The /embed block is different. It renders a sandboxed <iframe>pointing to a URL you control. The iframe loads that URL as a completely separate browser context with its own origin — it cannot read or modify the Notion page's DOM or cookies (that's what the sandbox means), but within its own origin it can run arbitrary JavaScript freely.
Analytics tools exploit this by serving a small HTML page from their own domain — for example, PageInsight serves a tracking page from pageinsight.satosushi.co. When the iframe loads, the tracking page's JavaScript runs. The first thing it does: fire a view event to PageInsight's API, passing the tracker ID (which maps to your specific Notion page), an anonymous visitor ID, and metadata like the referrer URL.
Conceptual flow
Visitor opens Notion page
→ Notion renders HTML including <iframe src="https://track.example.com/p/abc123">
→ Browser loads iframe source (from analytics origin)
→ Iframe JS fires: POST /api/view { trackerId, visitorId, referrer }
→ Server records view, returns { ok: true }
→ Iframe JS starts 30s heartbeat loop
→ Every 30s (while tab is active): POST /api/heartbeat { trackerId, visitorId }
→ Tab closes / navigate away → loop stops
→ Server calculates time_on_page = last_heartbeat - first_viewThe tracking pipeline: view → heartbeat → dashboard
Three events drive everything:
- 1. View event. Fires the moment the iframe document loads. The JavaScript running inside the iframe sends a POST to the analytics API with the tracker ID (embedded in the iframe src URL), a visitor ID (from localStorage — more on this below), the
document.referreras seen from Notion (which tells you the share source — Twitter, Google, etc.), and a timestamp. - 2. Heartbeat events. After the view fires, the iframe starts a setInterval loop that fires a heartbeat every 15–30 seconds while the tab is in the foreground (checked via the Page Visibility API). Each heartbeat tells the server the visitor is still reading. The loop clears when the visitor navigates away or closes the tab.
- 3. Server-side aggregation. The analytics server derives time-on-page from
last_heartbeat_at - first_view_at. This is more accurate than single-page-session bounce-rate math because it measures active foreground time, not "tab was open" time. The dashboard renders these aggregates in real time via a polling or WebSocket connection.
The lazy-loading problem and how to solve it
This is the most important limitation to understand about Notion embed tracking. Notion renders iframe embeds with lazy loading — the browser doesn't fetch the iframe source URL until the embed scrolls into the visitor's viewport. For an embed at the very bottom of a 5,000-word guide, a visitor who reads only the intro and leaves never triggers the iframe fetch, so never registers a view.
The practical implication: tracker placement is data. An analytics embed at the top of the page fires for virtually every visitor. The same embed at the bottom fires only for engaged readers who scrolled through the whole page. Both are valid measurements — but they measure different things.
For a "how many people opened this page" metric, you want the embed at the top. PageInsight auto-inserts the tracker near the top of each page via Notion's blocks/children PATCHAPI endpoint — so you don't have to think about it.
How visitor identity works (and doesn't)
The iframe runs on the analytics tool's origin, not notion.so. This has two consequences for identity:
- The tracker cannot read Notion authentication. Even if your Notion page is shared privately with workspace members, the tracker cannot access Notion's session cookies or know which Notion account is logged in. The browser's same-origin policy prevents cross-origin cookie access. You'll see reads from workspace members, but not which specific member.
- Visitor ID is anonymous and localStorage-scoped. The tracker generates a random UUID on first load and stores it in the iframe origin's localStorage. On subsequent visits to any page tracked by the same analytics tool, the same ID is reused — so repeat visits from the same browser are counted as one unique visitor. A visitor in two different browsers (e.g., Chrome and Safari) appears as two different visitors.
- No cookies, no fingerprinting. The localStorage approach means no third-party cookies and no cross-site tracking. It also means Incognito mode users always show as new visitors (localStorage is cleared between Incognito sessions).
What you can't track via an embed
The iframe sandbox is real — there are things that simply can't be done from inside an embed:
- Click tracking. The iframe cannot observe click events on links within the Notion page — those are in the parent document on a different origin. You can't know which link a visitor clicked.
- Scroll depth beyond the embed position. The tracker can tell you the embed fired (visitor reached that scroll depth) but not how far below the embed they scrolled. True scroll-depth tracking requires script access to the parent page.
- Heatmaps / session recording. These require DOM access on the parent page. Notion doesn't allow it.
- Conversion events. If you want to track whether a visitor clicked a CTA button and signed up, you need script-level access. Not available inside Notion.
- Who specifically read the page. No Notion account identity. Anonymous IDs only.
For most Notion use cases — guides, wikis, public pages, product docs — views, time-on-page, and geo are the three metrics that answer the real question: did anyone read this, and did they actually stay? The iframe mechanism gives you those reliably.
Frequently asked questions
Why can't Notion analytics tools use script tags?
Notion's page renderer strips all <script> tags from the output HTML. When Notion publishes a page to the web, it renders page blocks through its own React app — there's no <head> element exposed to page authors and any inline script content is escaped as plain text. This applies to Code blocks set to JavaScript too: the code displays as text, it doesn't execute in the browser.
What is Notion's /embed block and why does it work for analytics?
The /embed block in Notion renders a sandboxed <iframe> pointing to a third-party URL you specify. The iframe is sandboxed from the parent page (it cannot read Notion's cookies or DOM) but it can execute arbitrary JavaScript within its own origin. Analytics tools use this to run their tracking code: the iframe's JavaScript fires a view event immediately on load and sends heartbeat pings while the user stays on the page.
What is the Notion iframe lazy-loading limitation?
Notion renders iframe embeds lazily — an embed only fetches its source URL when it scrolls into the visitor's visible viewport. If your analytics tracker is positioned at the bottom of a long page, visitors who read only the introduction and leave without scrolling never trigger the iframe. This means you undercount reads if the tracker is placed far down the page. The solution is to place the tracker near the top, where it enters the viewport immediately on page load. PageInsight auto-inserts trackers at the top of each page via the Notion API.
How does heartbeat-based time-on-page work?
After the initial view event fires, the tracker iframe sends a 'heartbeat' ping to the analytics server every 15-30 seconds while the page tab is active. When the tab loses focus or closes, pings stop. On the server, the difference between the last heartbeat timestamp and the first view gives an accurate reading time. This is more honest than 'session duration' from standard analytics, which often includes time when the tab is open but unread.
Can the iframe tracker identify individual Notion visitors?
No. The tracker iframe runs on the analytics tool's origin (e.g., pageinsight.satosushi.co), not on notion.so. It cannot read Notion's authentication cookies or know which Notion account is logged in. PageInsight identifies visitors by an anonymous ID stored in the iframe's localStorage — scoped to the analytics tool's origin, not Notion's. This means the same Notion user on two different browsers looks like two different visitors.
Related guides
Try the analytics in practice
5 trackers free. No card. Connect Notion via OAuth and watch the view events fire.