While working on a blog, I wanted readers to know roughly how much time an article would take. Nothing complicated. Just a small label beside the date:
3 min read
The calculation is simple. The interesting part is deciding what to count.
Start with the smallest useful formula
I use 220 words per minute as a configurable assumption—not a promise about how fast everyone reads.
public static int EstimateMinutes(int words)
{
return Math.Max(1, (int)Math.Ceiling(words / 220d));
}
Why ceiling? Because 1.4 minutes should display as 2, not 1. Why a minimum of 1? Because “0 min read” looks odd on a short post.
| Counted words | Calculation | Label |
|---|---|---|
| 100 | 100 ÷ 220 = 0.45 | 1 min read |
| 220 | 220 ÷ 220 = 1 | 1 min read |
| 221 | 221 ÷ 220 = 1.005 | 2 min read |
| 500 | 500 ÷ 220 = 2.27 | 3 min read |
Count the writing, not the Markdown syntax
Counting the raw source also counts things readers never see, such as link destinations. Instead, I parse Markdown with Markdig and walk its text nodes.
This is a focused version of the calculation used on this blog. Add Markdig to your .NET project:
dotnet add package Markdig
using Markdig;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
using System.Text.RegularExpressions;
public static class ReadingTime
{
private static readonly MarkdownPipeline Pipeline =
new MarkdownPipelineBuilder().UsePipeTables().Build();
public static int Calculate(string markdown)
{
var document = Markdown.Parse(markdown, Pipeline);
var words = 0;
foreach (var node in document.Descendants())
{
if (node is LiteralInline literal)
{
words += Regex.Matches(
literal.Content.ToString(),
@"[\p{L}\p{N}]+(?:['’-][\p{L}\p{N}]+)*").Count;
}
}
return Math.Max(1, (int)Math.Ceiling(words / 220d));
}
}
Headings, paragraphs and list text contribute to the count. Formatting markers and link URLs do not. The pattern accepts Unicode letters and numbers and keeps words such as “don’t” together. It is a practical heuristic for this blog’s content, not a universal tokenizer for every language.
For example:
## A small improvement
Read the [setup guide](https://example.com/a/very/long/path).
The heading and link label count. The long URL does not. Image alternative text can also contribute because it is represented as literal text.
What about code examples?
In this implementation, fenced code blocks and inline code are excluded: they are different node types, not LiteralInline.
That keeps a large pasted sample from inflating the estimate. It also has a tradeoff: understanding code may take longer than reading the surrounding explanation.
I treat the label as prose reading time, not the time needed to complete the tutorial. A post with 500 counted words still shows “3 min read” even if it contains a long code block. A code-only post gets the minimum “1 min read”.
If your readers need a hands-on estimate, you could add a separate “About 15 minutes to try” note. That is a different measurement.
Calculate it once on the server
When an article is saved, the backend calculates the value. When an immutable revision is created, it calculates and stores that revision’s value too. Public pages receive the estimate belonging to the published revision.
That last detail matters: editing a draft should not change the reading time of the article people are currently reading.
The API returns a number:
{
"title": "A small improvement",
"readingTimeMinutes": 3
}
Angular only displays it:
<span>{{ article.readingTimeMinutes }} min read</span>
There is no second word-counting algorithm in the browser. Article cards and the article page use the same value, including server-rendered pages.
Check the boundaries
These are useful cases to keep in a test suite:
- 220 plain words → 1 minute.
- 221 plain words → 2 minutes.
- A long fenced code block → no extra counted words.
- A link with a long URL → count its label, not its destination.
- A draft edit → leave the published revision’s estimate unchanged.
A reading-time label does not need to be perfectly precise to be helpful. It just needs a clear rule, a consistent result, and a modest place beside the article metadata.