DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our article type says keep the reading time honest, and all eight of our pages overstate it

Notifio is a desktop app that watches rental listing search pages and tells you the moment something new appears. The marketing site around it carries eight long-form pages: five task guides under /guides and three pages written for a specific kind of searcher under /for/, like the student housing one.

Each of those pages is a typed object rather than MDX, and one of its required fields is how long the page takes to read. The field has a comment on it:

/** Rough minutes, shown under the title. Keep it honest. */
readingMinutes: number;
Enter fullscreen mode Exit fullscreen mode

I went to check whether it was. It is not, on any of the eight.

Where the number is claimed

readingMinutes is required by the Article type, so a page cannot ship without one. It renders in three places, all of them in front of a reader:

// ArticleLayout.tsx, under the H1 on the page itself
{article.eyebrow} ยท {article.readingMinutes} min read

// ArticleLayout.tsx, on each related-article card at the foot of the page
{other.readingMinutes} min

// guides/page.tsx, in the index listing
{article.readingMinutes} min
Enter fullscreen mode Exit fullscreen mode

It appears zero times in the structured data. The pages emit an Article node and a FAQPage node, and neither carries timeRequired. So this claim is made to people three times and to crawlers never, which, as we will see, is the correct split by accident.

Counting the words

The page bodies are data, not JSX, so I could have counted from the source. I counted from the live HTML instead, because that is what a reader actually gets, and because counting the rendered page is the stricter test: it sweeps up the breadcrumbs, the eyebrow, the standfirst, every heading, the FAQ block, the related-article cards and the sources list. Every one of those inflates the count in the page's favour.

body = re.search(r"<main.*?</main>", html, re.S).group(0)
body = re.sub(r"<script.*?</script>", "", body, flags=re.S)
text = re.sub(r"<[^>]+>", " ", body)
words = len([w for w in text.split() if any(c.isalnum() for c in w)])
Enter fullscreen mode Exit fullscreen mode
Page Declared Words Implied words/min At 220 wpm
/for/students 7 1,221 174 5.5
/for/expats 8 1,214 152 5.5
/for/amsterdam 8 1,081 135 4.9
/guides/how-fast-do-rental-listings-go 6 1,058 176 4.8
/guides/how-to-be-first-to-a-rental-listing 7 1,080 154 4.9
/guides/first-message-to-a-landlord 6 963 160 4.4
/guides/rental-documents-checklist 5 842 168 3.8
/guides/rental-scams-netherlands 6 1,168 195 5.3
Total 53 8,627 163 39.2

Eight pages, eight overstatements, zero understatements. The declared numbers imply a reader doing 163 words a minute. Silent adult reading of prose is usually put somewhere in the 200 to 260 range, and 238 is the figure from Brysbaert's meta-analysis of 190 studies, which is the number I would defend if someone asked. At 238 the whole set is 36 minutes against 53 claimed.

The worst single page is /for/amsterdam: 8 minutes claimed on 1,081 words, which needs a reader doing 135 words a minute to come true.

Why a hand-typed number drifts one way

The distribution is the interesting part. If the number were a bad guess you would expect a scatter, some high and some low. Getting eight out of eight in the same direction is not noise, it is a mechanism, and the mechanism is the order the work happens in.

The number gets typed when the page is drafted, from the draft that existed at the time. Then the page gets edited, and editing a page like this almost always makes it shorter: a section that repeated another one comes out, a paragraph of hedging becomes a sentence, three bullet points become two because the third was the first two restated. I have never once gone back and retyped the minutes afterwards. Nothing in the build has an opinion about it, because the field is typed as number and 8 is a perfectly good number.

So the field records the length of a draft that no longer exists. Every edit that improved a page made its reading time a little more wrong, which is a nicely perverse incentive to have built into your own content type.

It costs about ten lines to derive

The pages being data rather than prose is what makes this easy. Every word on the page is reachable from the object:

function wordsIn(article: Article): number {
  const strings = [
    article.h1,
    article.standfirst,
    ...article.sections.flatMap((section) => [
      section.heading,
      ...section.body,
      ...(section.points ?? []).flatMap((point) => [point.title, point.body]),
      ...(section.callout ? [section.callout.label, section.callout.text] : []),
    ]),
    ...article.faqs.flatMap((faq) => [faq.q, faq.a]),
  ];

  return strings.join(" ").split(/\s+/).filter(Boolean).length;
}

/** Rounded up, floor of 1, so a short page never claims "0 min read". */
export function readingMinutes(article: Article): number {
  return Math.max(1, Math.ceil(wordsIn(article) / 238));
}
Enter fullscreen mode Exit fullscreen mode

Ten lines, and the number can never drift again, because there is no longer a number: there is a function over the text, and the text is the thing being edited. The readingMinutes field comes off the type, which turns a whole category of staleness into a compile error in eight files, all of which are fixed by deleting a line.

Note what the signature does not take: a word count passed in, or a wpm argument. Both would be a way for the old problem to come back in through a different door.

The other defensible answer

The honest alternative is to delete the claim rather than compute it, and I considered it seriously.

A reading time is a promise about a reader you have never met. It assumes they are reading every word in order, which is not what anyone does with a checklist of rental documents. On the documents checklist the useful behaviour is to scan for the two items you do not have yet, and "5 min read" is noise next to that.

What the label actually buys is a decision about whether to start now, and it buys it in the index listing more than on the page itself. I am keeping it for that reason, derived rather than typed, and the rounding is deliberately generous: a label that says 6 and takes 5 costs nobody anything, while one that says 5 and takes 8 is the one that makes the reader feel lied to. That asymmetry is the entire argument for rounding up, and it is also exactly what the hand-typed numbers were accidentally doing. They were just doing it by 35 percent.

Takeaways

  • A number a human types next to content they are about to edit will drift in one direction, because editing prose makes it shorter. The direction is predictable, so check for the direction and not just the error.
  • If your content is data rather than markup, any claim about that content can be derived. Derive it, and then delete the field so nobody can type one again.
  • Count the rendered page, not the source, when auditing a claim made to readers. If the generous count still fails, you have no argument left to have.
  • A metadata field with a comment telling future maintainers to be honest is a sign that nothing enforces it. "Keep it honest" is not a constraint.

The app these pages are about is on the download page, and the help page is the one bit of writing on the site that makes no claim about how long it takes.

Top comments (0)