Skip to content

Performance and Threading

m4bwav edited this page Sep 30, 2026 · 2 revisions

The benchmark

The repository's BenchmarkDotNet project pretty prints a generated 1 MB minified document (nested objects, arrays, escaped quotes and backslashes, numbers, true, false, null, empty scopes) with PrettyPrintJson(). The numbers below are from one machine: AMD Ryzen 9 9900X, Windows 11, .NET 10.0.12, BenchmarkDotNet 0.15.8, ten iterations after three warm-ups. Compare them with each other rather than with your hardware.

Code Mean Allocated What changed
2.0.0 16.77 ms 29.07 MB The 2014 design: the input copied into a StringBuilder, a strategy object looked up in a dictionary per character, a new strategy allocated for every ordinary character, and the output backtracked to remove indentation.
2.1.x 11.09 ms 10.75 MB The input is read in place, line breaks are deferred instead of written and then removed, and the output goes to a TextWriter.
3.0.x 4.49 ms 10.35 MB One internal engine with a switch per character replaces the dictionary lookup and interface call.

On that machine 3.0.1 gets through about 230 million characters of minified JSON per second. The formatted document is 2.6 times the size of the input (2.7 million characters over 107,000 lines), and the 10.35 MB allocated is almost all the output string and the builder that grows into it.

Where the allocations are

The output is the cost: a StringBuilder sized at one and a half times the input, growing as the indentation pushes past that, then its ToString(). The engine itself allocates nothing per character. It keeps a bool[] scope stack that starts at sixteen entries and doubles when nesting goes deeper (a document nested a thousand levels deep formats fine).

To skip the output string, write to a TextWriter:

var printer = new JsonPrettyPrinter();
using var writer = File.CreateText("pretty.json");
printer.PrettyPrint(json, writer);

The input still has to be in memory as a string or a ReadOnlySpan<char>; there is no TextReader overload. Formatting a file therefore costs the file's size in memory for the input, plus the writer's buffer, and no second copy.

PrettyPrint(ReadOnlySpan<char>) formats part of a bigger string without a substring:

var pretty = printer.PrettyPrint(buffer.AsSpan(start, length));

The per-thread printer

PrettyPrintJson() without options keeps one JsonPrettyPrinter per thread (a [ThreadStatic] field) and reuses it, so a call from a hot path allocates only the output. PrettyPrintJson(options) creates a new printer for every call. A printer is small (an engine with the sixteen-entry stack), but if you format many documents with the same options, build one JsonPrettyPrinter(options) and reuse it.

Thread safety

Object Shareable between threads
PrettyPrintJson() and PrettyPrintJson(options) Yes. The first uses a printer per thread, the second a fresh printer per call. Twenty thousand calls from a Parallel.For gave twenty thousand identical results.
JsonPrettyPrinter instance No. It owns one engine whose state (open scopes, whether it is inside a string, the pending line break) is reset at the start of every call and used throughout it. Make one per thread, or lock around it.
JsonPrettyPrintOptions Yes. It is an immutable record; one instance can serve every printer in the process.
ToJson() and DeserializeFromJson() Yes. They wrap System.Text.Json, whose serializer and options are safe to share once in use.

A printer reused after a FormatException is clean: the engine resets its state before every document, so the failed one leaves nothing behind. The repository has a test for exactly that.

Tips

  • Format once, not per log line. If a message is written several times, keep the pretty text.
  • Two-space indentation (IndentSize = 2) or tabs (UseTabs = true) makes the output smaller; IndentSize = 0 smaller still, with every value still on its own line.
  • For a document you will parse anyway, JsonSerializer.Serialize(JsonNode.Parse(json), options) with WriteIndented = true does the parse and the formatting in one pass. This library is for the case where you have text and want it readable without parsing it, or where the input is not strict JSON.

Clone this wiki locally