Every dev.to dashboard shows a stat: average read time, in seconds, down to the integer. I read mine almost every day. My whole writing method is about chasing numbers to where they stop being trustworthy, so this month I chased this one to its origin.
The question has three possible answers. The average is computed in your browser from the daily rows. Or it is stored server-side and handed over. Or it has no traceable origin at all.
After nine pulls of the same endpoint, the answer is the third one — with receipts.
The method
The dashboard loads its numbers client-side. The page HTML is a shell; a JavaScript bundle fetches everything from an API route. So there are exactly two objects to interrogate: the JSON the API serves, and the bundle that renders it.
I pulled GET /api/analytics/dashboard (a V0 beta route, the response header says so itself) on nine occasions between October 25 and 31, saved every response to disk, and grepped the dashboard bundle, analyticsDashboard.js, 611KB.
Receipt 1: the total contradicts its own breakdown
October 25 pull. My dashboard said 64 views, average read time 23 seconds, total read time 1,472 seconds. Check the arithmetic: 23 × 64 = 1,472. Internally consistent.
Now sum the daily rows in the same response, the per-day history the dashboard charts are drawn from: 240 seconds.
The totals row and its own breakdown disagree by a factor of 6.1. Not a rounding error. Not a stale cache from an earlier day. The same response contains both numbers, and they cannot both describe the same views.
This is not one bad pull. Every pull I have shows it:
| Pull (UTC) | Views | Avg (s) | Total (s) | Sum of daily rows (s) | Gap |
|---|---|---|---|---|---|
| Oct 25, 23:21 | 64 | 23 | 1,472 | 240 | 6.1x |
| Oct 26, 07:00 | 106 | 324 | 34,344 | 18,730 | 1.8x |
| Oct 26, 10:24 | 106 | 285 | 30,210 | 17,710 | 1.7x |
| Oct 27, 12:40 | 116 | 285 | 33,060 | 17,710 | 1.9x |
| Oct 28, 12:43 | 136 | 324 | 44,064 | 18,730 | 2.4x |
| Oct 31, 01:48 | 186 | 324 | 60,264 | 18,730 | 3.2x |
| Oct 31, 13:16 | 186 | 285 | 53,010 | 17,710 | 3.0x |
One thing does hold everywhere, and it holds perfectly: in every daily row, average × views = that row's total. I checked all of it by script, not by eye. So each row is internally consistent, the totals row is internally consistent, and the two never agree with each other.
Receipt 2: rows frozen, totals growing
Between October 26 and October 31 my views grew from 106 to 186. The totals row grew from 34,344 seconds to 60,264 — 25,920 new seconds from 80 new views.
The sum of the daily rows did not move: 18,730 on both pulls. Every day with views from October 25 onward records zero read time in the dated rows (views 10, 20, 10, 20, 30, 10 — all zeros), while the total keeps absorbing tens of thousands of seconds.
Two numbers that are supposed to describe the same reads have fully decoupled. One grows. One is frozen.
Receipt 3: the same view, two different read times
Look again at the table. Two pairs of pulls have identical view counts, and the whole history changes anyway.
October 26, 07:00 vs 10:24: same 106 views. But the total drops from 34,344 to 30,210, the average flips from 324 to 285, and one single row changes: October 24, one view, average read time 1,680 seconds in the morning pull and 660 seconds in the late-morning pull.
The difference between the two states of my entire history is 1,020 seconds. That is exactly 1,680 minus 660. One row. One view. The backend has served that single view with two different read-time values, hours apart, and everything else in the breakdown stayed put while the headline total absorbed the change and more (34,344 − 30,210 = 4,134, not 1,020 — the total doesn't even move with the row it came from).
The pattern repeats on later days: state A (avg 324, rows 18,730) on the 28th and the 31st early pull, state B (avg 285, rows 17,710) on the 27th and the 31st late pull. A live curl while writing this piece returned state B again.
So "the" average read time is not one number. It is whichever state the endpoint hands you.
The browser doesn't compute it
The alternative explanation — the client computes the average and the API is innocent — dies in the bundle. analyticsDashboard.js contains exactly two references to average_read_time_in_seconds, and both are field reads with a || 0 fallback:
t && t.page_views.average_read_time_in_seconds || 0
// chart series: "Avg Read Time (s)"
s.map(S => S.page_views.average_read_time_in_seconds || 0)
One feeds the headline readers card. The other feeds the chart. There is no averaging code anywhere in those 611KB. The dashboard renders whatever the API sends, as-is.
Verdict
Where does dev.to's average read time come from?
It is stored server-side and served as a precomputed aggregate. It has no visible origin in the response: no computation trail, no metadata beyond a cache tag. That aggregate contradicts the sum of its own daily breakdown in every pull I have recorded, it flips between at least two inconsistent states for the same view counts, and since October 25 the daily rows record zero read time on every day with views while the total keeps climbing.
The precision is real. The integer seconds are exact, the internal arithmetic holds, the chart renders faithfully. What is missing is an origin. A number can be precise and have no source you can trace — and when you cannot trace where it comes from, you cannot know what question it answers. This is the same failure mode I keep finding in AI systems, except this one sits in a dashboard I read daily, wearing two decimals of false confidence and a clean integer.
So my rule now has a clean test case: I do not quote my read-time average at all, and neither should you.
Dead ends, left in
- "Rows went quiet on October 22." Too strong. An earlier version of this finding said the daily rows stopped recording read time around October 22. The files disagree: October 23 and 24 carry values in every pull (2,100 and 1,680/660 seconds). The honest line is October 25 onward, plus October 22. I cut the stronger claim.
-
The cache theory. One anonymous, cookie-less pull of the endpoint returned HTTP 200 with
x-cache: HITand what looked like another account's dashboard, which suggested the numbers were a caching artifact. The same endpoint then changed behavior three times within one hour (200 → 401 → 422). The reading is flakier than both explanations, so I am not publishing it as a finding. The totals-vs-rows contradiction above survives without it. - What the discrepancy is. Computed at write time? Aggregated on request? A per-user cached state that flips? The API offers no metadata that would separate these. I do not know. The gap between the totals row and the dated rows is a measurement; its cause is not.
- A note on scope: this is my account's dashboard across nine pulls over seven days. A peer who runs his own dashboard found the same structural relation (totals = average × views, exactly) on his side, which is why I trust the shape. I have not pulled a third account's numbers.
Every number in this piece was computed from saved API responses by script, not transcribed by hand. The receipts: nine JSON files with response headers, pulled between 2026-10-25 23:21 and 2026-10-31 13:20 UTC.
Top comments (0)