At endjin, we maintain Corvus.JsonSchema, and in the previous post we looked at schema validation.
Now let's talk about the foundation that makes V5's performance possible: pooled-memory parsing.
The allocation problem
If you've profiled a .NET service that processes a lot of JSON, you've probably seen a familiar pattern in the GC profiler: a steady stream of small allocations from JsonNode, JsonObject, and JsonArray. Each node in the mutable document model is a separate heap object. For a document with 100 properties, that's 100+ allocations. Each one contributes to GC pressure.
System.Text.Json.JsonDocument solves this with a pooled model, but it's read-only. The moment you need to modify the JSON, you're back to JsonNode and its per-node allocations.
V5's ParsedJsonDocument<T> gives you the best of both: a pooled, read-only document that uses ArrayPool<byte> for all its backing memory, with just 136 bytes of GC pressure per document. This is true regardless of size.
And when you do need to modify things, the JsonDocumentBuilder we'll discuss in the next post uses the same pooled memory model.
Basic usage
Parsing from a string
using var doc = ParsedJsonDocument<JsonElement>.Parse(
"""
{
"name": "Alice",
"age": 30,
"address": {
"city": "London",
"country": "UK"
}
}
""");
JsonElement root = doc.RootElement;
string name = root.GetProperty("name"u8).GetString();
int age = root.GetProperty("age"u8).GetInt32();
The using statement is important. When the document is disposed, all rented memory is returned to ArrayPool. This is the core lifetime rule for ParsedJsonDocument<T>. There are no leaked buffers, and no GC pressure beyond the 136 bytes for the document object itself.
Parsing from UTF-8 bytes
If you already have UTF-8 data from a network buffer, a file read, or an HTTP request body, you can parse directly without transcoding:
ReadOnlyMemory<byte> utf8Data = GetUtf8FromNetwork();
using var doc = ParsedJsonDocument<JsonElement>.Parse(utf8Data);
Async stream parsing
For large files or network streams, async parsing avoids blocking:
using FileStream stream = File.OpenRead("large-data.json");
using var doc = await ParsedJsonDocument<JsonElement>.ParseAsync(stream);
UTF-8 property access
One of the subtle performance wins in V5 is that property names are stored and compared as UTF-8 bytes. The "name"u8 syntax gives you a ReadOnlySpan<byte> - no string allocation, no UTF-16 transcoding.
// Fast: UTF-8 comparison, no allocation
string name = root.GetProperty("name"u8).GetString();
// Also works, but transcodes from UTF-16
string name = root.GetProperty("name").GetString();
For properties you access frequently, V5 can optionally build an O(1) property map for repeated lookups. This is an opt-in feature. You enable it when you know you'll be accessing the same properties repeatedly and want to avoid the cost of linear scanning.
Types are views, not containers
This is a key concept, and it's worth exploring in more detail.
In most .NET serialization frameworks, a deserialized object owns its data. A Person class has a string Name field backed by its own heap-allocated string. The data lives in the object.
In V5, that's not what happens. A generated Person struct is just two fields: a reference to its parent IJsonDocument, and an int index into that document's metadata table. That's it. The struct doesn't hold the string "Alice". It holds a pointer to where "Alice" lives in the document's pooled UTF-8 byte buffer.
┌──────────────────┐ ┌─────────────────────────────────────┐
│ Person struct │ │ ParsedJsonDocument (pooled) │
│ ┌────────────┐ │ │ ┌─────────────────────────────┐ │
│ │ _parent ───┼──┼─────▶│ │ MetadataDb (token offsets) │ │
│ │ _idx: 0 │ │ │ ├─────────────────────────────┤ │
│ └────────────┘ │ │ │ UTF-8 value buffer │ │
└──────────────────┘ │ │ {"name":"Alice","age":30} │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────┘
This means:
- Creating a typed view is free.
doc.RootElement doesn't copy anything. It returns a struct with the document reference and index 0. Accessing person.Name returns another struct pointing at the same document with a different index.
- Multiple views share the same data. You can have
Person, JsonElement, and Address structs all pointing into the same document. No duplication.
- The document owns the lifetime. When you dispose the
ParsedJsonDocument, the pooled memory goes back to ArrayPool. Any struct that still references it becomes invalid. This is why the using statement matters.
So far we've used JsonElement, but the real power comes from typed documents. Given a generated Person type (from the source generator in post 2):
using var doc = ParsedJsonDocument<Person>.Parse(
"""{"name":"Alice","age":30}""");
Person person = doc.RootElement;
string name = (string)person.Name; // "Alice"
int age = (int)person.Age; // 30
bool valid = person.EvaluateSchema(); // true
person is a view. person.Name is a view. Neither allocates. The only allocation is the 136-byte document object itself.
Extended types
V5 supports types beyond what System.Text.Json offers natively:
| JSON Schema format |
.NET Type |
Example |
"format": "int128" |
Int128 |
Large integer IDs |
"format": "uint128" |
UInt128 |
Large unsigned integer IDs |
"format": "half" |
Half |
Low-precision floats |
| Arbitrary precision integer |
BigInteger |
Cryptographic or scientific values |
| Arbitrary precision |
BigNumber |
Financial or scientific values |
"format": "uri" |
Utf8UriValue |
Absolute URIs |
"format": "uri-reference" |
Utf8UriReferenceValue |
Absolute or relative URIs |
"format": "iri" |
Utf8IriValue |
Internationalized URIs |
"format": "iri-reference" |
Utf8IriReferenceValue |
Internationalized URI references |
"format": "date" |
NodaTime.LocalDate |
Calendar dates |
"format": "date-time" |
NodaTime.OffsetDateTime |
Timestamps with offset |
"format": "duration" |
NodaTime.Period |
ISO 8601 durations |
BigNumber is rarely needed, but when you do need it, it is essential. It's a custom arbitrary-precision decimal type that operates directly on the UTF-8 bytes of the JSON, without intermediate conversion to double or decimal. There is no precision loss and no floating-point surprises.
Every JSON type - JsonElement and all generated types - implements IFormattable, ISpanFormattable, and IUtf8SpanFormattable on .NET 9+. On netstandard2.0 the interfaces aren't available, but the underlying static formatting methods are still there, so you can call them directly. For numeric elements, this means you can format values with standard .NET format strings:
using var doc = ParsedJsonDocument<JsonElement>.Parse("""{"price": 1234.5}""");
JsonElement price = doc.RootElement.GetProperty("price"u8);
// String formatting with culture support
string display = price.ToString("C", CultureInfo.GetCultureInfo("en-GB"));
// "£1,234.50"
// String interpolation (uses IFormattable)
string message = $"Total: {price:N2}";
// "Total: 1,234.50"
For zero-allocation hot paths, write directly to a UTF-8 byte span:
Span<byte> buffer = stackalloc byte[64];
if (price.TryFormat(buffer, out int bytesWritten, "F2", CultureInfo.InvariantCulture))
{
ReadOnlySpan<byte> utf8Price = buffer.Slice(0, bytesWritten);
// Write to a Utf8JsonWriter, HTTP response, or log sink - no string allocation
}
The standard numeric format specifiers are all supported: G (general), F (fixed-point), N (number with grouping), E (scientific), C (currency), and P (percentage).
At a glance
|
JsonNode |
JsonDocument |
ParsedJsonDocument<T> |
| Memory model |
Per-node allocation |
Pooled, read-only |
Pooled, read-only (mutable via builder) |
| GC pressure |
~1,528B (typical) |
~480B |
~136B |
| Mutable |
Yes |
No |
Via JsonDocumentBuilder |
| Schema validation |
No |
No |
Yes |
| Property access |
O(1) |
O(n) |
O(n), optional O(1) property map |
| Generic |
No |
No |
Yes (IJsonElement<T>) |
Next up
We've seen how V5 pools memory for read-only documents. But what about mutation? In the next post, we'll look at JsonDocumentBuilder and JsonWorkspace. They provide the pooled, version-tracked builder pattern that's at the heart of V5's design trade-off with V4.