Kntnt Engagement Metrics

How Do You Know If Anyone Actually Reads What You Publish?

Every analytics platform tells you how many people visited a page. None of them tells you how many people read it. The difference matters more than most publishers realise – and a small open-source library called Kntnt Engagement Metrics sets out to close that gap. Here is how it works, and why it is running on this very page.


What page views actually measure

Web analytics tools – Google Analytics, Matomo, Adobe Analytics and their many competitors – are built around a simple event: a page was loaded. From that event they derive page views, unique visitors, sessions, bounce rates and time on page. These numbers are useful for understanding traffic patterns, but they say nothing about what happened after the page appeared on screen.

Time on page, which might seem like a proxy for reading, is particularly misleading. Most analytics platforms calculate it as the difference between the timestamp of one page view and the next. If a visitor reads an article for eight minutes and then closes the browser, the time on page is recorded as zero – because there was no subsequent page view to compare against. The metric systematically undercounts engaged readers and overcounts casual visitors who click through to another page quickly.

Bounce rate has a similar problem. A visitor who arrives, reads the entire article carefully, finds exactly what they were looking for and leaves is recorded as a bounce – indistinguishable from someone who landed on the wrong page and left immediately.

The result is a measurement system that conflates attention with presence. Being on a page is not the same as reading it.

Scroll depth: a step in the right direction

The industry’s most common attempt to get closer to actual engagement is scroll depth tracking. The idea is straightforward: if a visitor scrolls to the bottom of the page, they probably read it. Many analytics setups fire events at 25 %, 50 %, 75 % and 100 % scroll depth.

This is better than page views alone, but it has an obvious limitation. Scrolling and reading are different activities. A visitor can flick to the bottom of a 3,000-word article in two seconds without reading a single paragraph. Conversely, someone reading a short article that fits entirely above the fold never scrolls at all – yet they may read every word.

Scroll depth measures movement through a page. It does not measure attention to the content on that page. The two happen to correlate in some cases, but the correlation is weak enough to be unreliable for editorial decisions.

What would be needed is a way to observe which parts of the page the visitor actually pauses on – and for how long.

A different approach: timing what is visible

Kntnt Engagement Metrics takes a different path. Rather than measuring where the visitor scrolls or how long they stay, it measures how long each piece of content remains visible in the browser viewport – and whether the visitor is actually pausing to look at it.

The core idea rests on a reasonable assumption: if a paragraph is fully visible on screen and the visitor is not scrolling, there is a good chance they are reading it. The longer the paragraph remains visible and stationary, the higher the probability. This is not a certainty – someone might stare out of the window while a paragraph sits on screen – but it is the closest approximation available without eye-tracking hardware.

The library distinguishes between two behaviours. Reading means the visitor pauses on content long enough to plausibly consume it. Scanning means the visitor scrolls content into view but moves on without pausing. From these two behaviours it derives two continuous metrics, each on a scale from 0 to 1: a reading ratio and a scanning ratio. Together, they paint a far more detailed picture than page views, bounce rate or scroll depth alone.

The following sections explain how each part of this process works – enough to understand the library without reading the source code.

Finding text to measure

When the library starts, it searches the page for all HTML elements that match a CSS selector. The default is p, which targets every paragraph element, but the selector is configurable. A publisher measuring an article page might use article :is(h1, h2, h3, p, ul, ol) to include headings and lists within the article element.

If an exclusion selector has been configured, any element inside an excluded area – a sidebar, a footer, a table of contents – is filtered out. Elements that are hidden (zero height) or contain no text are also discarded. The remaining elements form the content that will be measured, in the order they appear on the page.

A countdown timer for each element

Each element gets its own countdown timer. The timer’s duration is calculated from the element’s character count and a configurable reading speed, expressed in characters per minute. Research shows that readers of text written with the Latin alphabet typically spend between 42 and 45 milliseconds per character, regardless of language and text difficulty. That translates to roughly 1 330–1 430 characters per minute. The default of 1 380 characters per minute is the midpoint of that range. A paragraph with 690 characters, for example, gets a 30-second timer. This represents the library’s estimate of how long an average reader needs to read that particular element.

The reading speed can be changed at runtime. When it is, the library recalibrates every timer while preserving its current progress – there is no reset, no lost state. An element that was 60 % read remains 60 % read, but the remaining time adjusts to reflect the new speed.

Watching what is on screen

The library uses the browser’s built-in IntersectionObserver API to monitor which elements are visible in the viewport and how much of each element is showing. The observer reports changes at five thresholds – 0 %, 25 %, 50 %, 75 % and 100 % visibility – so the library always has a reasonably precise picture of how much of an element the visitor can see.

This approach is efficient. The browser itself handles the geometry calculations on its compositor thread, rather than the library polling element positions on every frame. The library simply receives a callback when a visibility boundary is crossed.

When an element appears in the viewport for the first time, the library also records how far down the page the visitor has reached. This is the scanning depth: the deepest point scrolled to so far. Scanning depth only increases – scrolling back up does not reduce it.

The reading model

Five times per second, the library runs a measurement tick. Each tick looks for the topmost element that is visible and has not yet been fully read, and advances that element’s countdown timer. Only one element advances per tick. This models a human reader who finishes one paragraph before moving to the next.

There is one important constraint: reading progress cannot exceed the element’s visible boundary. If only half of a paragraph is on screen, the timer advances until 50 % of the estimated reading time has elapsed, then waits. Once the visitor scrolls to reveal more of the element, the timer continues. This prevents counting text as read when it is not yet visible.

If the visitor scrolls past a paragraph without pausing, the timer for that element never advances – or advances only partially. The library moves on to the next visible element. If the visitor later scrolls back up, reading resumes from where it left off. Progress is never lost.

Pausing during scrolling and tab switches

While the visitor is actively scrolling, reading timers do not advance. The library detects scrolling by measuring scroll speed; once the speed drops below a threshold and remains low for 50 milliseconds, reading resumes. This is the mechanism that distinguishes scanning from reading: a visitor who scrolls steadily through the page produces a high scanning ratio but a low reading ratio, while a visitor who pauses on each paragraph produces high values for both.

Timers also pause whenever the browser tab is hidden. If a visitor switches to another tab for five minutes, those five minutes are not counted as reading time. The Page Visibility API provides this signal, and the library’s tick loop is automatically suspended by the browser when the tab is not visible.

Computing the metrics

After each tick, the library computes a snapshot of aggregate metrics from all tracked elements. Two of these metrics are the most immediately useful. The reading ratio is the fraction of the page’s text content estimated to have been read – each element contributes its character count multiplied by its timer’s progress, and the sum is divided by the total character count across all elements. The scanning ratio is how far down the page the visitor has scrolled, relative to the total scrollable height.

A reading ratio of 0.02 and a scanning ratio of 0.95 means the visitor scrolled through nearly the entire page without pausing. A reading ratio of 0.85 and a scanning ratio of 0.90 means they read most of the content carefully. A reading ratio of 0.45 and a scanning ratio of 0.50 means they read the first half and left. Each of these patterns is invisible to traditional analytics but clearly distinguishable with engagement metrics.

The snapshot also includes the raw values behind those ratios: reading time (estimated seconds of actual reading so far), content time (the total time it would take to read everything), reading length and content length (characters read versus total characters) and scanning depth and content depth (pixels scrolled versus total scrollable pixels). These are available to any code that registers as a listener.

Connecting to analytics platforms

Measuring engagement in the browser is only half the problem. The data also needs to reach whatever analytics system the publisher uses, so it can be analysed alongside traffic data, conversion events and other metrics.

Kntnt Engagement Metrics handles this through a listener interface. The core library emits a metrics snapshot on every measurement tick – roughly five times per second. Any piece of code that implements a simple update method can subscribe to these snapshots and do whatever it wants with the data.

The project ships with two ready-made listeners as separate packages: one for Matomo and one for Google Analytics 4. Both follow the same pattern. They define a set of configurable thresholds and fire an analytics event the first time each threshold is crossed. Each threshold fires exactly once per page visit, so a fully engaged reader generates a handful of events rather than a continuous stream.

The two add-ons ship with different default thresholds. The Matomo add-on fires reading events at every ten percentage points (10 %, 20 %, 30 % and so on up to 100 %), while the GA4 add-on uses a sparser set (10 %, 25 %, 50 %, 75 %, 90 % and 100 %). Both use the same scanning thresholds: 25 %, 50 %, 75 % and 100 %. The difference reflects the platforms’ different constraints. A self-hosted Matomo instance has no event quotas and no sampling – more granular data is simply better. GA4’s free tier, by contrast, applies sampling at high event volumes, enforces daily event quotas and limits the number of custom dimensions per property. Fewer thresholds reduce the risk of hitting those limits. Both sets of thresholds are configurable – these are defaults for sites that do not specify their own.

The Matomo add-on pushes events via Matomo’s _paq array and can optionally set custom dimension values for reading ratio, scanning ratio and reading time. The GA4 add-on calls gtag() with custom event names. Both auto-detect whether their respective analytics tracker is present on the page and log a warning to the browser console if it is not – without throwing errors or breaking anything.

Because the listener interface is open, building an integration for a different analytics platform is a matter of writing a small class that receives metrics snapshots and translates them into the appropriate API calls. The core library does not need to know or care which analytics system is on the other end.

When the measuring stops

Once every element has been fully read and the visitor has scrolled to the bottom of the page, both the reading ratio and scanning ratio reach 1. The library then stops its measurement loop and releases its observers and event listeners, leaving zero overhead on the page. There is nothing left to measure.

The overlay: seeing engagement in real time

The page you are reading right now does not use Matomo or Google Analytics. Instead, it uses a third add-on called the overlay, which visualises the measurement process directly in the browser.

The overlay serves two purposes. It is a demonstration tool – a way to see the library in action and verify that its assumptions about reading behaviour are reasonable. And it is a diagnostic tool – useful during development for calibrating the reading speed parameter and checking that the measurement algorithm behaves as expected on different page layouts.

When the overlay is active, each tracked paragraph on the page gets a coloured outline. Blue means the element has not yet been scrolled into view. Gold, with a gradient showing progress, means the element is currently visible and its reading timer is advancing. Red means the element was previously visible but has scrolled out of view before being fully read. Green means the timer has completed – the element has been visible and stationary for long enough to count as read.

In one corner of the screen, a small panel displays live metrics: reading percentage, scanning percentage, the number of elements in each state and an interactive reading speed control. The speed control is a slider (100–3,000 characters per minute) and numeric input that adjusts the assumed reading speed in real time. Move it up, and the timers require less time per element; move it down, and they require more. Because the timers preserve their current progress when recalibrated, the effect is immediate and smooth.

The overlay panel uses Shadow DOM, which means its styles are completely isolated from the rest of the page. It cannot be affected by the page’s CSS, and it cannot affect the page’s layout. Pressing Ctrl+Shift+D (configurable) toggles the overlay on and off.

Try scrolling this page at different speeds and watch the colours change. Pause on a paragraph and observe the gold gradient creeping forward. Switch to another tab and come back – the timers will have paused while you were away. This is the measurement algorithm made visible.

Why the library loads fast

Web performance is a constant concern for publishers. Every additional script competes for bandwidth and processing time, and visitors on slow connections or older devices feel the impact most.

The core library is 5.0 KB minified – small enough to load in a single TCP round trip on most connections. The overlay add-on, which includes all its CSS and HTML generation code, adds 6.9 KB. The Matomo and GA4 add-ons are 1.2 KB and 1.4 KB respectively. A typical production deployment – the core plus one analytics add-on – weighs under 6.5 KB. For comparison, a single medium-resolution JPEG image on most web pages is ten to fifty times that size.

This small footprint is a direct consequence of a strict design rule: the core library has zero runtime dependencies. No utility libraries, no framework code, no polyfills. Every byte in the bundle is code that the library itself needs.

Why it will not slow down the page

Size is only one dimension of performance. A small script that monopolises the browser’s main thread can still degrade the user experience. Kntnt Engagement Metrics is designed to avoid this.

The measurement loop runs on requestAnimationFrame, throttled to one tick every 200 milliseconds. Each tick does the minimum possible work: it finds the one element that needs advancing, and updates a single timer with basic arithmetic. No DOM reads happen during the tick. The browser’s own compositor thread handles visibility detection through the IntersectionObserver API, which is vastly more efficient than polling element positions with getBoundingClientRect() on every frame.

All scroll and touch event listeners are registered with the { passive: true } flag. This tells the browser that the listener will not call preventDefault(), allowing the browser to handle scrolling on a separate thread without waiting for JavaScript to respond. The practical effect is that the library cannot introduce scroll jank – the stuttering, laggy scrolling that some poorly written tracking scripts cause.

When the browser tab is hidden, requestAnimationFrame is automatically suspended by the browser itself, and the library’s visibility handler pauses all timers. No CPU cycles are spent on a tab the visitor is not looking at.

Why it will not break anything

A tracking library that crashes the page or interferes with other scripts is worse than no tracking at all. Kntnt Engagement Metrics takes several precautions to ensure it stays contained.

Every call to a registered listener is wrapped in a try/catch block. If an analytics add-on throws an error – perhaps because the analytics platform’s script failed to load – the error is caught, logged to the console as a warning, and the remaining listeners continue to receive updates. A broken add-on cannot take down the core measurement loop, and a broken measurement loop cannot take down the page.

Invalid CSS selectors passed to the configuration are caught and handled with a warning rather than an exception. Missing analytics trackers (a page where Matomo or gtag.js is not loaded) produce a console warning, not a crash. The library treats every external dependency – the DOM, the analytics platform, the add-ons – as potentially unreliable and handles failures gracefully.

The overlay’s user interface is rendered inside a Shadow DOM boundary, which provides complete style isolation in both directions. The page’s CSS cannot leak into the overlay, and the overlay’s CSS cannot affect the page. This eliminates an entire category of visual bugs that can occur when injecting UI elements into arbitrary web pages.

Private class fields (using JavaScript’s native # syntax rather than TypeScript’s private keyword) ensure that the library’s internal state cannot be accidentally modified by other scripts on the page. The public API surface is deliberately small: a handful of methods on the measurer object, and that is all.

Why it is not a security risk

The library runs entirely in the visitor’s browser. It does not send data to any server on its own – that is the job of whichever analytics add-on is configured, using the analytics platform’s own established channels. The core library makes no network requests of any kind.

It does not read cookies, access localStorage, collect personal information or fingerprint the browser. It observes two things: which content elements are visible in the viewport, and whether the page is being scrolled. These are the same observations that any IntersectionObserver-based lazy-loading image script makes.

The source code is published under the MIT licence by Kntnt Sweden AB. MIT is one of the most permissive open-source licences: anyone can use, modify and redistribute the code, including in commercial products, with no obligation beyond preserving the copyright notice. The full source – TypeScript, tests, build configuration, documentation – is available for inspection on GitHub.

Where to find it

The source code, documentation and issue tracker are at github.com/Kntnt/kntnt-engagement-metrics. The repository includes detailed technical specifications covering the measurement algorithm, the public API, coding conventions, the add-on development guide and the testing strategy. The packages will be published on npm under the @kntnt scope once the library reaches its first stable release.

Anyone who publishes content on the web and wants to understand whether that content is being read – not just visited – now has a tool that attempts to answer that question as honestly as the constraints of a web browser allow. It is small, it is fast, it is cautious, and it shows its working. The overlay on this page is the proof.