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 rootMargin of '-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.visibilityState becomes hidden, 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.

The Reading BandA viewport with negative top and bottom root margins shrinking the root to a band across its middle. A section inside the band is accumulating read time. A section only visible at the top edge, outside the band, is not counted. A section just below the viewport is not counted.section 2 — visible at top edge, not countedsection 3 — in the band, accumulating timesection 4 — below, not countedSolid blue frame: reading band (middle 30%). Dashed frame: rootMargin -45px.

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

TypeScript
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

TypeScript
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.

One Section's Read Time Across a VisitA timeline of one section during a visit. The section enters the reading band and accumulates time. The reader switches tabs; the interval closes and nothing accumulates while hidden. The reader returns and time accumulates again. The reader stops interacting; after the idle timeout the interval closes until the next scroll.Section "Installation", one visitin bandsection overlaps the reading bandpagetab hiddenidle, pausedcounted30 s30 s20 s0s25s50s75s100s125s150s175s

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.

Effective Reading Speed by SectionA bar chart of median effective reading speed per section of a guide, in words per minute. The introduction was read at about two hundred and forty words per minute. Installation at about two hundred and ten. The configuration reference was skimmed at about nine hundred. Troubleshooting was read closely at about one hundred and eighty.Median words per minute while in the reading bandIntroduction240 wpm — readInstallation210 wpm — readConfiguration reference900 wpm — skimmedTroubleshooting180 wpm — read closely

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.


↑ Back to Dynamic Visibility Tracking