DEV Community

Cover image for I Published a Reading Time 2,680 Times, 618 of Them Add Up
Mustafa ERBAY
Mustafa ERBAY

Posted on Originally published at mustafaerbay.com.tr

I Published a Reading Time 2,680 Times, 618 of Them Add Up

This morning, looking over the frontmatter of the article that had just gone out, one line caught my eye:

readingTime: 14
Enter fullscreen mode Exit fullscreen mode

Fourteen minutes. A promise to the reader, four characters long. Then came a simple question: where does that number come from? As far as I remembered, a script computed it, and since March I had been trusting that script. But "a script computes it" is not an answer — it is the comfort we put in place of one.

So I sat down and counted. The site's own posts-index.json returns 2,680 pages, and without exception every single one carries a reading time. 2,062 of those numbers do not equal the value computed by my own formula, living in my own repository. One example: a career article telling the reader fourteen minutes has a formula answer of seven. Total deviation: 7,241 minutes — roughly 120 hours that nobody was charged but everybody was told.

The genuinely annoying part came after the deviation. I looked at the formula itself, and the formula turned out to have very little to do with reading.

2,680 Pages, 618 Consistent Numbers

The whole count was taken at one moment, at the then-current tip of origin/main (e9f90ff). Importing the repository's own library (scripts/lib/reading-time.mjs), each file's frontmatter value went up against the value that same library produces. The page count matches the live posts-index.json exactly: 2,680. (Two of them carry draft: true in their frontmatter, yet both return 200 live and appear in the feed — a subject for another article; from a reader's side, all of them are published.)

Metric Turkish English
Pages 1,340 1,340
Exactly matching the formula 316 (23.6%) 302 (22.5%)
Off by 1 minute 258 246
Off by 3 minutes or more 562 599
Claiming longer than it is 748 574
Claiming shorter than it is 276 464
Total deviation 3,559 min 3,682 min

The two languages resembling each other this closely surprised me, because there is a gate between them that I knew about — more on that shortly.

One article sits at the far end of the distribution: a technology post dated 18 May 2026 promises the reader 20 minutes while its body holds 296 words. The formula's answer is two minutes. No second article is wrong at that scale (the next largest deviation is 12 minutes), so I will not write the sentence "my blog makes things up"; that number is an accident, the rest is a habit. But 2,062 pages is not a figure you can call an accident either.

The asymmetry in the table says something too: of the 1,024 deviating Turkish articles, 748 claim longer than they are. The direction of the error is not random, it leans toward inflation. The reason shows up later — old, short articles with generous numbers typed over them by hand.

Here is where I owe you a distinction. When I say "2,062 pages are wrong," my yardstick is my own formula; the precise reading is these numbers disagree with the repository's own arithmetic. Whether the formula measures reading correctly is a separate matter, and the second half of this article is exactly where that claim falls apart. The first finding is a consistency problem, the second is a meaning problem. They should not be mixed.

What the Script Named "fix" Actually Fixes

A script written for precisely this job sits in the repository: scripts/fix-reading-times.mjs. Ambitious name, registered in package.json as fix:reading-times. Here is the whole loop:

const content = readFileSync(file, 'utf8');
const match = content.match(/^readingTime:\s*([^\r\n]+)$/m);
const actual = Number(match?.[1]);
const expected = estimateReadingTime(content);
if (!Number.isInteger(actual) || actual < 1 || actual > 60) {
  mismatches++;
  console.log(`${write ? 'fixed' : 'invalid'} ${file}: ${String(match?.[1])} -> ${expected}`);
  if (write) writeFileSync(file, setDeterministicReadingTime(content));
}
Enter fullscreen mode Exit fullscreen mode

The fourth line computes the right answer. expected then appears once more — but only in the log line, which is to say after the if has already opened. It never enters the decision. The condition checks three things: is the number an integer, is it below 1, is it above 60. In other words, the shape of the number is audited; the truth of the number is not. That 20 sitting on top of a 296-word article walks straight through this gate: integer, between 1 and 60, perfectly shaped, entirely wrong.

I want to underline that distinction, because I misread it myself at first: this is not a "computed but unused variable." The variable is used — just not where the ruling is made, only where the ruling is narrated. The right answer is sitting right there and the file has no need of it, because it never asks anyone "is this number wrong," only "is this number malformed."

The script's name does not help either: when you open a file with "fix" in its name six months later, you do not read its contents, you believe its name. My own notes even carried a line about this script: "it skips the EN files." Checking today: it does not. The file filter is /\.mdx?$/, and of the 2,680 matching files in that folder, 1,340 are .en.mdx. So the script sees every English file, it just never questions any of them. The note was wrong, and the script is both broader and blinder than I expected. My own recipe misdescribing the code is not new to me — I have counted the seven claims in my recipe that the code did not support before.

On top of that: no workflow under .github/workflows/ mentions reading time at all. The script is a command you invoke by hand, not an automatic gate. It sits in the repository, and nobody asks it to run.

Shape-checking showed up a third time in this story. The relevant line of the content schema (src/lib/blog-frontmatter-schema.mjs) reads:

readingTime: z.number().int().min(1).max(60),
Enter fullscreen mode Exit fullscreen mode

A sound rule — it cuts absurd values off at 60, lets through neither text nor negative numbers. But the 20 belonging to a 296-word article sits right in the middle of that range. In three separate places the number's shape is checked; its truth is checked in one place, and even there only for the new files of a single language.

The Gate Exists in One Language, Not the Other

The English side is in better shape — but only for new articles. scripts/validate-en-post.ts really does compare:

} else if (strictPolicy) {
  const expectedReadingTime = estimateReadingTime(content);
  if (readingTime !== expectedReadingTime) {
    errors.push(
      `readingTime is not deterministic: ${readingTime}, expected ${expectedReadingTime} at 200 words/min`,
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

The correctness gate lives here, inside the strictPolicy branch. That branch only opens with the --staged or --strict-policy argument, which means the gate inspects English files not yet committed. It inspects none of the 1,340 pages already published.

On the Turkish side that gate does not exist at all. The word readingTime does not appear even once in scripts/validate-mdx-staged.ts. The text I write in my native language, the one published first and shared on social platforms first, has its reading time verified at no stage; its translation does.

And that is not even the most uncomfortable part. The repository's own documentation, AGENTS.md, states that the gate exists:

Reading time: readingTime is computed deterministically at the publish gate over the body at 200 words/min; do not write the target word count or a model estimate.

There is no such "publish gate" on the Turkish side. The document describes a check that the code performs in one language only, and only for staged files. On top of that, the frontmatter template in my article-writing recipe says readingTime: <words/250 rounded> — the recipe divides by 250 while the code divides by 200. So there is a fourth constant in play, and it is wrong in documented form. I wrote my own rules from what I remembered of my code rather than from the code.

Diagram

What I Counted as a "Word"

The entire formula is one line: divide the word count by 200, round up. So how does that word count come about? The library strips the frontmatter, the import lines, HTML tags, link syntax and URLs out of the text. Inside code blocks, however, it strips only the fence line:

.replace(/```
{% endraw %}
[^\n]*\n?/g, ' ')
{% raw %}

Enter fullscreen mode Exit fullscreen mode

This pattern deletes the `

bash
line, not the lines inside the block. Which means the code itself gets counted like prose. A deliberate positive confirmed it:

text
prose only : 5 words
code block added : 11 words

Adding a six-word systemctl line to a five-word sentence pushed the count to eleven. My complaint is not that this is a "bug" — reading code also takes time, often slower than prose. My complaint is that 200 words per minute is a figure quoted for prose, and these blocks are being held to the same speed. Across the whole blog, code blocks contribute 109,023 of the 1,945,158 counted Turkish words — 5.6%. It looks small, but because of the rounding it changes the minute shown on screen for 492 of 1,340 articles. In one tutorial, six of the nineteen minutes are code.

In this morning's article it is two of fourteen minutes: eleven code blocks holding 228 words.

So is there one correct way to count words? There is not, and that is not my opinion. The W3C Internationalization Glossary, a group note dated 17 October 2024, settles it in a single sentence: "The concept of 'word' is difficult to define in any language." Browsers have a standard tool for this job; MDN introduces Intl.Segmenter as "locale-sensitive text segmentation" and shows by example why split(" ") collapses on Japanese.

The same article, counted four ways:

Method Input Words Minutes
The repository's regex stripped body 2,611 14
Intl.Segmenter('tr') stripped body 2,572 13
Splitting on whitespace stripped body 2,557 13
src/utils/content.ts raw file 2,664 14

That last row is the interesting one. Inside src/utils/content.ts there is a second function named estimateReadingTime: it does no stripping at all, counts the raw file — frontmatter and import lines included — with split(/\s+/), and divides by 200. It is exported, and not a single line calls it. Two functions, one name, different inputs, different word counts — and in this article they happen to land on the same minute. There are 53 words between 2,664 and 2,611; both fall into the same rounding box, so the difference never reaches the screen. You might think the dead twin confirms the living one; it does not, it merely lands in the same box this time. While counting the nineteen scripts my code never calls I had grown used to dead twins of this kind; this one hides a different answer behind the rounding.

The gap between the methods is visible in a single sentence:

js
"Disk 12 GiB dolu — yani %73 — ve read_ahead_kb 128."
// regex : 9 words
// split whitespace : 11 words (both em dashes count as words)

The Same Article, Two Different Durations

The Turkish and English pages carry the same text but show different numbers on screen. For this morning's article the Turkish page says 14 minutes, the English page 17. The reason is simple and unavoidable: English carries the same meaning with more words. Across the 316 pairs whose Turkish number is computed correctly, the ratio comes out at a mean of 1.199 and a median of 1.192 (lowest 0.668, highest 1.373).

A single example shows why this is a mechanical outcome:

text
TR: "Bilgisayarlarımızdaki dosyaları yedekleyemediğimizden endişelenmiştik."
4 words / 70 characters
EN: "We had been worried because we could not back up the files on our computers."
15 words / 76 characters

The eye scans nearly the same amount of ink — 70 characters against 76. The word count is four against fifteen. Any formula using a per-word speed constant assigns one of these two sentences three and a half times the duration of the other. Turkish runs on suffixes; "yedekleyemediğimizden" counts as one word but does not take one word's worth of time to read. Applying a 200 words-per-minute constant to Turkish text is like reading a road measured in metres off a ruler marked in yards: you get a number, it just does not mean what it says.

The translation script (scripts/translate-post.ts) already knows this. The comment in its code is explicit: carrying the Turkish number over verbatim is wrong at the 200 words-per-minute gate, so the value is recomputed from the English text. The right call — but a correctness that satisfies the gate, not the reader. The result is two different promises for one article: 14 minutes for a Turkish reader, 17 for an English one. Which is right? Neither; both are word counts.

In the older pairs the number stayed copied. Of the 316 pairs whose Turkish number is correct, 163 show the identical number on the English page; 133 of those (82%) are wrong by their own word count, and those 133 pages understate by an average of 1.47 minutes.

October's pairs show just how regularly the mechanism works:

Published TR EN TR words EN words
15 Oct 14 min 17 min 2,611 3,399
14 Oct 13 min 17 min 2,577 3,324
13 Oct 20 min 26 min 3,904 5,098
12 Oct 15 min 20 min 2,887 3,807

English runs three to six minutes longer on every row. On no row because English readers read more slowly; on every row because English spent more words saying the same thing.

Two Numbers on the Same Page

Pulling the live page down turned up something interesting. In the HTML of this morning's article the value shown to the reader is 14 minutes; in the structured data of that same page sits this:

json
"wordCount": 2525

2,525 words, divided by 200, is 13 minutes. So a single page tells a human 14 while publishing a number that makes a machine say 13. The two figures come from two different files: one from the MDX source via regex, the other counted off the rendered HTML by splitting on whitespace (scripts/migrate-content.mjs). I did not break the 86-word difference down item by item — with different inputs and different methods the gap is expected, but I should say plainly that its full composition remains unmeasured.

There is also this: the page publishes wordCount and never publishes timeRequired. Yet schema.org is exactly where a reading time belongs. Its definition names the crux: "Approximate or typical time it usually takes to work with or through the content of this work" and then, immediately — "for the typical or target audience." For the typical or target audience. There is no audience in my formula. There is a word count, there is a 200, there is a rounding-up; there is nobody reading.

Google's article structured data documentation (updated 8 September 2026) defines no required properties and recommends five top-level properties: author (listed across seven table rows together with author.name and author.url), dateModified, datePublished, headline, image. Neither wordCount nor timeRequired appears in that list. So the number I tell machines earns nothing in search appearance, and the number I tell humans is never published at all. Two separate numbers, two separate audiences, no arithmetic between them.

The Trace of Years: the Pipeline Healed, the Archive Stayed

Spreading the deviation across publication months made the table suddenly readable:

Month Articles Matching the formula Mean deviation
2026-04 368 25 (7%) 5.2 min
2026-05 274 64 (23%) 1.9 min
2026-07 200 51 (26%) 1.7 min
2026-09 96 37 (39%) 1.8 min
2026-10 41 38 (93%) 0.2 min

On 24 August 2026 the content pipeline moved to agent-based production. Before that date: 1,170 Turkish articles, 19.3% consistent, 3,320 minutes of total deviation. After: 170 articles, 52.9% consistent, 239 minutes. In October the rate is 93%.

I got this table wrong the first time, and the correction belongs to the story: I was collecting the dates with a grep like ^publishDate: 2026-05, and one file had written its date in quotes (publishDate: "2026-05-02"). That single quotation mark undercounted May by one. The article about measuring wrongly had itself been measured wrongly because of an edge case; I had tested my scanner with a deliberate positive in the code and never done the same for the dates.

So the pipeline healed. A healed pipeline does not heal the archive behind it — nobody went back to recompute, because nobody knew the arithmetic did not hold. And here I have one more thing to admit: I did know. Inside src/pages/about.astro, right above the line that sums the total word count, sits my own comment:

ts
// bozuk-veri korumasi: makale basi cap ~60dk (12000 kelime) — birkac makalede readingTime absurd olabilir
return sum + Math.min(w, 12000);

Translated: bad-data guard: cap at ~60 min (12,000 words) per article — readingTime may be absurd in a few articles. So at some point I noticed that some articles' reading times could be absurd, and what I did was not to correct the number but to shield the screen from it. The statistics page no longer breaks; the broken number sits where it was, still being shown to readers. This is the very trap I fell into looking at my own archive before — in the mechanism that generates 4,008 recommendations while never mentioning 537 articles the fault was not in the production either, but in a scope nobody had measured. And asking one number four ways and getting two answers feels familiar from the day I asked five separate tools how much space was left on my disk and found a 13.5 GiB spread. The difference is this: disk usage was a question I asked myself. This one I tell the reader.

I also counted how far the number spreads: under src/, excluding content files, 17 files touch readingTime. One of them (src/pages/api/analytics.ts) computes its own minutes from wordCount when the value is missing — which adds yet another counting method to the ones in this article. And there is ReadingStats.astro: when the value is missing, a default of 5 minutes is assigned. A constant placed there on the theory that something beats nothing; but it is a line of code refusing to say "I don't know."

What I Will Do With This Number

Removing the reading time crossed my mind and stayed there: picking from the cards, I go by that signal myself, and I have no measurement of what readers do with it. What is wrong is not the signal, it is that I never looked at it. The order comes out like this:

  1. Put the correctness gate on the Turkish side too. Add to validate-mdx-staged.ts the exact comparison already working in English. A new article should not be committable with a wrong number — a class closed at the source. The same work turns the gate described in AGENTS.md into a real one.
  2. Repair the condition in fix-reading-times.mjs. Let the computed value sit inside the if rather than in the log: actual !== expected is a mismatch as well. A one-line change that makes 2,062 pages visible.
  3. Recompute the archive in one pass, but produce the distribution first. The fix will touch 2,062 pages; committing that in one go without knowing how many pages move by how many minutes, and in which direction, would be blind work for updatedDate and the sitemap. I will not start the correction before that table exists.
  4. Count code blocks separately. Counting prose at 200 and code at a slower rate is possible; adding two figures beats pretending they are one. I do not know the right constant, but "code does not read at the speed of prose" is a sentence that can be measured.
  5. Question the constant for Turkish. 200 words per minute is too generous for a language where each word carries more meaning. I do not know the right figure; reading a few of my own articles against a clock is the only honest measure I have to start from.
  6. Publish timeRequired, or drop wordCount. Let the number a human sees and the number handed to machines come from the same file; their coming from separate sources is the reason for today's one-minute contradiction.

None of this is urgent work. A wrong reading time loses nobody's data and wakes nobody up; it just spends a small fraction of trust on every page.

Closing

The count handed me a list of defects, but the list is not what stayed with me. What stayed is this: on 2,680 pages I make the reader a promise, and I had never checked the arithmetic behind it. I even had a script written to check it; it computes the right answer, writes it to the log, and never lets it near the ruling. Building a gate and never looking behind it is more dangerous than building no gate — because when you build no gate, you know it.

And one thing remains: a number sits on top of this article too. The script computed it, the formula is the same formula, the code blocks were again counted like prose, and Turkish is still subject to 200 words per minute. So that number too is, at best, a word count divided by 200. What I learned writing this is not that the number is wrong — it is that I never asked what it measured. Every number we publish is a promise to a reader who assumes a question stands behind it; the question we do not ask, nobody asks on the reader's behalf.

Official Sources

Top comments (0)