Measure section read time by observing each section against a reading band (the middle of the viewport, via a negative rootMargin), accumulating the time each section spends inside it with performance.now(), pausing when the tab is hidden or the reader is idle, and sending one summary when the page is hidden.
Problem / Scenario Context
A documentation team wants to know which sections of their long guides people actually read and which they skip. The existing analytics only record scroll depth — 75% of readers reach the end — which says nothing about whether they read the middle or flicked past it. A first attempt counts a section as "read" when any part of it appears on screen, which credits every section a reader scrolls past on the way to the one they want.
Time-in-view, measured precisely and conservatively, is a far better signal. The Dynamic Visibility Tracking topic introduces visibility metrics; this page builds a read-time tracker.
Mechanics Explanation
An IntersectionObserver delivers an entry whenever a section crosses a threshold, with entry.time telling you exactly when the crossing was computed. Accumulating read time then needs only two events per section: entered the band (start an interval) and left the band (close it and add its length).
Three refinements turn raw visibility into a credible attention signal:
- A reading band, not the whole viewport. Readers focus on the middle of the screen. A
rootMarginof'-35% 0px -35% 0px'shrinks the root to the middle 30%; a section "is being read" while it overlaps that band. Sections glimpsed at the edges during a fast scroll barely register. - Pause when attention is impossible. When
document.visibilityStatebecomeshidden, close every open interval; reopen them when the page is visible again. Rendering-step observers do not fire in hidden tabs, so without this, a section that was in the band when the reader switched tabs accumulates time for an hour. - Pause for idle readers. A reader who walks away leaves the page visible. Closing intervals after, say, 30 seconds without scroll, key, pointer or touch activity, and reopening on the next activity, removes that inflation.
Comparison Table: Attention Signals
| Signal | What it measures | Inflated by | Deflated by |
|---|---|---|---|
| Scroll depth | furthest point reached | fast scrolling to the end | — |
| Section "seen" (any pixel) | glimpses | passing through | — |
| Time in viewport | on-screen time | edges, idle readers, hidden tabs | — |
| Time in reading band | focused on-screen time | idle readers (unless paused) | readers who read near edges |
| Time in band, paused on hidden/idle | active focused time | little | brief attention lapses |
Minimal Reproducible Example
const start = new Map<Element, number>();
const total = new Map<string, number>();
new IntersectionObserver((entries) => {
for (const e of entries) {
const id = e.target.id;
if (e.isIntersecting) start.set(e.target, e.time);
else if (start.has(e.target)) {
total.set(id, (total.get(id) ?? 0) + e.time - start.get(e.target)!);
start.delete(e.target);
}
}
}).observe(document.querySelector('#install')!);
Leave the tab in the background for ten minutes with the section on screen: it is credited with ten minutes of reading when you return and scroll away.
Production-Safe Solution
interface ReadTimeOptions { sections: HTMLElement[]; idleMs?: number; endpoint: string }
export function trackReadTime({ sections, idleMs = 30_000, endpoint }: ReadTimeOptions): () => void {
const inBand = new Set<Element>(); // currently overlapping the band
const openedAt = new Map<Element, number>(); // open intervals
const totals = new Map<string, number>();
let active = document.visibilityState === 'visible';
let idleTimer = 0;
const open = (el: Element, t = performance.now()) => { if (active && !openedAt.has(el)) openedAt.set(el, t); };
const close = (el: Element, t = performance.now()) => {
const s = openedAt.get(el);
if (s === undefined) return;
totals.set(el.id, (totals.get(el.id) ?? 0) + (t - s));
openedAt.delete(el);
};
const pauseAll = () => { active = false; inBand.forEach((el) => close(el)); };
const resumeAll = () => { active = true; inBand.forEach((el) => open(el)); };
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (e.isIntersecting) { inBand.add(e.target); open(e.target, e.time); }
else { inBand.delete(e.target); close(e.target, e.time); }
}
}, { rootMargin: '-35% 0px -35% 0px' });
sections.forEach((s) => io.observe(s));
const onActivity = () => {
if (!active && document.visibilityState === 'visible') resumeAll();
clearTimeout(idleTimer);
idleTimer = window.setTimeout(pauseAll, idleMs);
};
const ac = new AbortController();
for (const type of ['scroll', 'pointerdown', 'keydown', 'touchstart'] as const) {
addEventListener(type, onActivity, { passive: true, capture: true, signal: ac.signal });
}
onActivity();
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
pauseAll();
const payload = Object.fromEntries([...totals].map(([id, ms]) => [id, Math.round(ms / 100) / 10]));
if (Object.keys(payload).length) navigator.sendBeacon(endpoint, JSON.stringify({ page: location.pathname, seconds: payload }));
totals.clear(); // each hide sends only new time
} else {
onActivity();
}
}, { signal: ac.signal });
return () => { pauseAll(); io.disconnect(); ac.abort(); clearTimeout(idleTimer); };
}
entry.time is used for interval boundaries driven by crossings, so the measurement is accurate to the frame in which the crossing was computed rather than when the callback happened to run. The activity listeners are passive and do almost nothing — they reset a timer — so they add no meaningful cost even on scroll. Totals are sent as seconds with one decimal and cleared after each send, so a reader who switches away and back produces two small beacons rather than double-counted totals.
Interpreting the Data
Read time is noisy per visit and informative in aggregate. A few practices make it useful:
- Normalise by length. Divide seconds by the section's word count to get an effective reading speed; sections read at 200–300 words per minute were read, sections at 2,000 words per minute were skimmed.
- Use medians, not means. A handful of readers who leave a tab open with activity (a presentation, a screen share) skew means badly even with idle detection.
- Compare sections within a page. Absolute seconds depend on the audience; the relative distribution across sections shows where attention goes and where it falls off.
- Pair with scroll-depth drop-off from measuring scroll depth without scroll listeners to separate "skipped" from "never reached".
Respect privacy regulations and consent: attention data is behavioural analytics, and in many jurisdictions it needs the same consent as other analytics cookies or identifiers.
Verification Steps
- Scroll quickly past sections and confirm they accumulate under a second each.
- Leave the tab hidden for several minutes and confirm no time accrues.
- Stop interacting with the page visible and confirm accumulation stops after the idle timeout.
- Check the beacon on tab hide contains only time since the last send.
- Compare with a manual stopwatch while reading a section normally.
Common Mistakes to Avoid
- Using the full viewport as the root. Edge glimpses inflate every section.
- Forgetting hidden tabs. Intervals must close on
visibilitychange. - Ignoring idle readers. A visible, unattended page accumulates indefinitely.
- Sending a beacon per crossing. Aggregate and send once per hide.
FAQ
Why use entry.time instead of performance.now() in the callback?
entry.time is when the browser computed the crossing, which is when the section actually entered or left the band. The callback may run later on a busy page; using its clock would shift interval boundaries by the delivery delay.
How wide should the reading band be?
A band covering the middle 30 to 40 percent of the viewport works well for articles. Narrow it for long sections that fill the screen, widen it for short cards.
Does a section taller than the band count correctly?
Yes. A tall section overlaps the band for as long as any part of it is in the middle of the screen, which is the time the reader spends in it.
Are the activity listeners expensive?
No. They are passive and only reset a timer. The observer does the geometric work; the listeners only detect presence.
What about readers using screen readers?
Screen-reader users may not scroll the viewport at all, so visibility-based read time undercounts them. Treat read time as one signal among several, not as a complete measure of engagement.
Should read time be sent for every page view?
Only with consent where required, and ideally sampled on high-traffic pages. The aggregate distribution is what matters, and sampling keeps collection costs down.
Related
- Measuring Scroll Depth Without Scroll Listeners — reach versus attention
- Measuring Video View Time with IntersectionObserver — the same interval technique for media
- Deferring Non-Urgent Observer Work with requestIdleCallback — sending analytics cheaply
↑ Back to Dynamic Visibility Tracking