ReverseMarkdown passes 7.5M downloads; 6.3.0 halves memory on real-world pages

ReverseMarkdown passes 7.5M downloads; 6.3.0 halves memory on real-world pages

ReverseMarkdown has passed 7.5 million downloads on NuGet. In January, when 5.0.0 shipped, it was at 3.7M. The first million took more than seven years (the package went up in July 2015 and was still under 500K at the end of 2021), and this year alone added more than the library’s entire history before it.

ReverseMarkdown total NuGet downloads, 2019 to 2026 Total downloads grew from about 79K in October 2019 to 1.0M in March 2023, 3.7M at the 5.0.0 release in January 2026, and 7.5M in October 2026. 0M 2M 4M 6M 8M 2020 2021 2022 2023 2024 2025 2026 5.0.0 6.0.0 Oct 20, 2019: 78,587 downloads (nuget.org via Wayback Machine) Dec 2, 2020: 275,696 downloads (nuget.org via Wayback Machine) May 19, 2021: 388,942 downloads (nuget.org via Wayback Machine) Nov 28, 2021: about 494.6K downloads (nuget.org via Wayback Machine) Mar 29, 2023: about 1.0M downloads (nuget.org via Wayback Machine) Mar 3, 2024: about 1.5M downloads (nuget.org via Wayback Machine) Apr 30, 2025: about 2.5M downloads (nuget.org via Wayback Machine) Jul 2, 2025: about 2.7M downloads (nuget.org via Wayback Machine) Oct 13, 2025: about 3.2M downloads (nuget.org via Wayback Machine) Jan 8, 2026: about 3.7M downloads (5.0.0 release post) Aug 26, 2026: about 6.4M downloads (preprocessing post) Oct 21, 2026: about 7.5M downloads (nuget.org) 2026 3.7M → 7.5M 3.7M 7.5M 1.0M
Total NuGet downloads. Points before 2026 are nuget.org snapshots from the Wayback Machine; hover a point for its date and source.
DateTotal downloadsAddedPer day
January 20252.2M
January 2026 (5.0.0)3.7M+1.5M in 12 months~4,100
August 2026 (6.2.1)6.4M+2.7M in 7.5 months~11,700
October 2026 (6.3.0)7.5M+1.1M in 8 weeks~20,000

The library more than doubled its all-time download count in under ten months, and over the last eight weeks it has averaged around 20,000 downloads a day, nearly five times last year’s pace.

I don’t think that is because HTML to Markdown suddenly got more interesting as a problem. It is because Markdown became the format LLMs read. Every RAG pipeline, documentation assistant and agent that browses the web needs to turn HTML into something compact and structured enough to be worth spending context tokens on, and a lot of those in .NET land on ReverseMarkdown. That is also why so much of this year’s work has been about real-world HTML: the preprocessing pipeline for stripping page chrome, InlineText table cells for Markdown that gets read rather than rendered, and now a benchmark corpus built from the kind of pages those pipelines actually feed in.

The release that lands on top of that, 6.3.0, is small by line count and satisfying by effect:

  • Allocations drop on every real-world page in the benchmark corpus, by 50 to 58% on the script-heavy ones.
  • Conversion time improves by 15% on the GitHub Markdown page and 13% on small pages.
  • New Convert(html, TextWriter) and ConvertAsync(...) overloads write Markdown straight to a file or an HTTP response.
  • No breaking changes. Everything new is additive.

Benchmarking pages people actually convert

Until now, ReverseMarkdown’s benchmarks used synthetic fixtures: a thousand paragraphs, ten thousand paragraphs, one very large HTML file. They are good at catching regressions in the core pipeline and bad at telling you what happens on the HTML that shows up in practice.

For 6.3.0 I measured against a corpus of real pages instead: Wikipedia articles, framework docs (React, Vue, Nuxt), MDN and a GitHub-rendered Markdown page, borrowed from the mdream project. The first run pointed straight at a problem the synthetic fixtures could never have found.

The fix: one copy per tag became one pass

Real pages are full of <script> and <style> elements, and some of them are not closed properly. ReverseMarkdown has a cleanup pass that deals with unclosed <script> and <style> tags before parsing. That pass copied the rest of the document once per tag it handled.

On a hand-written HTML fragment with no scripts, the cost is zero. On a modern documentation page with dozens of inline scripts, JSON-LD blocks and style tags, it is dozens of near-full-size string copies on every single conversion. The work was quadratic in disguise: it grew with page size multiplied by tag count.

The pass now runs in linear time, walking the document once. Here is what that does, measured with BenchmarkDotNet, published 6.2.1 against 6.3.0, default flavor, .NET 9.0 on an Apple M1, three launches each:

ReverseMarkdown 6.2.1 vs 6.3.0 on real-world pages Percentage change from 6.2.1 to 6.3.0 per page. Allocated memory: nuxt-example -21%, vuejs-docs -32%, wikipedia-small -20%, mdn-array -55%, react-learn -50%, github-markdown-complete -58%, wikipedia-largest -11%. Mean time: nuxt-example -13%, vuejs-docs -2%, wikipedia-small -1%, mdn-array -1%, react-learn -3%, github-markdown-complete -15%, wikipedia-largest +1%. Allocated memory change vs 6.2.1, lower is better nuxt-example: 133.9 KB → 105.6 KB (−21%) nuxt-example 3.5 KB −21% vuejs-docs: 4.82 MB → 3.3 MB (−32%) vuejs-docs 110 KB −32% wikipedia-small: 7.7 MB → 6.19 MB (−20%) wikipedia-small 162 KB −20% mdn-array: 17.68 MB → 8.0 MB (−55%) mdn-array 230 KB −55% react-learn: 13.99 MB → 6.97 MB (−50%) react-learn 259 KB −50% github-markdown-complete: 24.68 MB → 10.48 MB (−58%) github-markdown-complete 420 KB −58% wikipedia-largest: 222.5 MB → 198.6 MB (−11%) wikipedia-largest 1.77 MB −11% −60% −40% −20% 0% Mean execution time change vs 6.2.1, lower is better nuxt-example: 72.7 μs → 62.9 μs (−13%) nuxt-example 3.5 KB −13% vuejs-docs: 4.39 ms → 4.32 ms (−2%) vuejs-docs 110 KB −2% wikipedia-small: 7.49 ms → 7.42 ms (−1%) wikipedia-small 162 KB −1% mdn-array: 9.77 ms → 9.69 ms (−1%) mdn-array 230 KB −1% react-learn: 10.32 ms → 10.01 ms (−3%) react-learn 259 KB −3% github-markdown-complete: 11.57 ms → 9.79 ms (−15%) github-markdown-complete 420 KB −15% wikipedia-largest: 175.1 ms → 176.4 ms (+1%) wikipedia-largest 1.77 MB +1% −60% −40% −20% 0% shaded: ±5%, treated as unchanged
BenchmarkDotNet, .NET 9.0, Apple M1, default flavor, three launches each, on pages from the mdream corpus. Allocations are deterministic; timings vary by a few percent between runs. Hover a row for the absolute numbers.
PageSizeMean time 6.2.1 → 6.3.0Allocated 6.2.1 → 6.3.0
nuxt-example3.5 KB72.7 → 62.9 μs (-13%)133.9 → 105.6 KB (-21%)
vuejs-docs110 KB4.39 → 4.32 ms (-2%)4.82 → 3.30 MB (-32%)
wikipedia-small162 KB7.49 → 7.42 ms (-1%)7.70 → 6.19 MB (-20%)
mdn-array230 KB9.77 → 9.69 ms (-1%)17.68 → 8.00 MB (-55%)
react-learn259 KB10.32 → 10.01 ms (-3%)13.99 → 6.97 MB (-50%)
github-markdown-complete420 KB11.57 → 9.79 ms (-15%)24.68 → 10.48 MB (-58%)
wikipedia-largest1.77 MB175.1 → 176.4 ms (+1%)222.5 → 198.6 MB (-11%)

To read this honestly: allocations are deterministic, so every number in that column is a real reduction. Timings are not. The clear wins are github-markdown-complete (-15%) and nuxt-example (-13%). Everything else is within ±3%, which is inside run-to-run noise, so treat those pages as unchanged in speed.

Why bother if the time barely moves? Because the memory is where a busy service feels it. Halving allocations per conversion means half the garbage for the GC to collect, and when you are converting thousands of pages for a crawler or a RAG ingestion job, that shows up as steadier throughput and fewer Gen 2 pauses long before it shows up in a single-conversion stopwatch.

Write Markdown to a TextWriter

The second half of the release goes after the other big allocation: the output string itself. Convert returns the whole Markdown document as a string, and if you are about to write that string to a file, you have paid for a full-size copy just to throw it away.

6.3.0 adds overloads that take a TextWriter and write to it directly:

var converter = new Converter();

// Renders straight into the writer: the Markdown is never held as one string.
using var file = new StreamWriter("page.md");
converter.Convert(html, file);

Render gets the same treatment, for when you have already parsed the document and maybe transformed the Markdown DOM:

var converter = new Converter();

var document = converter.Parse(html);
converter.Render(document, output);

The output is byte-for-byte identical to the string overloads, including trimming and Formatting.OutputLineEnding. The converter writes but never flushes, closes or disposes the writer; that stays with you.

And asynchronously, for ASP.NET Core

ASP.NET Core disallows synchronous writes to the response body by default, so a StreamWriter over Response.Body throws InvalidOperationException as soon as its buffer fills. ConvertAsync and RenderAsync await every write and take a CancellationToken:

var converter = new Converter();

// For a stream that disallows synchronous writes, such as an ASP.NET Core response body.
// leaveOpen: the stream belongs to the caller; disposing the writer flushes it asynchronously.
await using var writer = new StreamWriter(responseBody, leaveOpen: true);
await converter.ConvertAsync(html, writer, cancellationToken);

A few things worth knowing before you reach for these:

  • The win is peak memory, not speed. The converter still renders into an internal buffer; it skips the full-size copies the string overload makes to produce the final string.
  • Only the output streams. The HTML input is still a string, parsed into an in-memory document before anything is written.
  • Parsing and rendering stay synchronous in the async overloads. Only the writes are awaited.
  • Slack, CommonMark and GitHub flavors still build the string internally before writing it out. The output is correct, but there is no memory saving with those three yet.

Other changes

  • Custom writers deriving from MarkdownWriterBase get a protected WritePostamble hook for content after the body and footnotes. MultiMarkdown abbreviation definitions now use it.
  • Benchmark tooling for contributors: a script to fetch the real-world corpus, corpus and throughput benchmarks, and -p:UseLocalReverseMarkdown=true to measure a working tree instead of a published package.

If you want to reproduce the numbers above on your own hardware:

./scripts/fetch-benchmark-corpus.sh

# Quick summary in MB/s of input HTML, per size group (takes seconds)
dotnet run -c Release --project src/ReverseMarkdown.Benchmark -- throughput

# Full BenchmarkDotNet run over the corpus, three launches for honest error bars
dotnet run -c Release --project src/ReverseMarkdown.Benchmark -- --filter '*Corpus*' --launchCount 3

How we got here: 5.0 to 6.3 in ten months

6.3.0 sits at the end of the busiest year the library has had. If you last looked at ReverseMarkdown around 5.0.0 in January, a lot has changed:

  • 5.0.0 (January): the TextWriter refactor that made huge HTML up to 200× faster, and the end of the nested <p> exponential slowdown.
  • 5.4.0 / 5.5.0 (June, July): lazy-loaded image fallback (data-src and friends), and net46 / netstandard2.0 targets supported again.
  • 6.0.0 (July): a ground-up rewrite. An AngleSharp HTML5 parser replaced HtmlAgilityPack, feeding a Markdown DOM rendered by per-flavor writers. Seven output flavors (including new MultiMarkdown and Pandoc), full CommonMark (651/651) and GFM (672/672) spec round-trips, and roughly 2 to 2.6× faster with 2.7× less memory than 5.5.0, all while keeping the public API source-compatible.
  • 6.1.0 (July): trimming and Native AOT compatibility.
  • 6.2.x (August): the HTML preprocessing pipeline, which I wrote up in detail in fix the input, not the output.
  • 6.3.0 (today): the release you just read about.

If ReverseMarkdown is already in your pipeline, 6.3.0 is a drop-in upgrade: no breaking changes, and less memory on every page in the corpus.