Mert Özen Further With Every Line
Backend I/O in .NET — #05

System.Text.Json in Depth 2: Source Generation, JsonNode/Utf8JsonReader and Streaming Large JSON

Mert Özen Nov 14, 2026 13 min 1 views
System.Text.Json in Depth 2: Source Generation, JsonNode/Utf8JsonReader and Streaming Large JSON

Deserializing a large JSON export into a List<T> pushes the heap past twice the file size; with DeserializeAsyncEnumerable the same job finishes in small, constant memory. We measure this on a 1-million-order Bookstore export and cover the startup and fast-path gains of source generation, its behavior under Native AOT, choosing between JsonDocument, JsonNode and Utf8JsonReader, JsonSchemaExporter and migrating from Newtonsoft.Json.

Version note: The code in this post was built and run with .NET 10 (System.Text.Json 10) and BenchmarkDotNet 0.15.8.

Guess First

Every month the accounting team of Bookstore, the online bookstore used as the shared example across this site's series, receives a 348 MB JSON export with 1 million orders. The code that computes total revenue looks like this:

List<Order> all = (await JsonSerializer.DeserializeAsync<List<Order>>(file))!;
decimal revenue = all.Sum(o => o.Total);

How many MB does the managed heap reach during this operation: below 348 MB, around it, or more than double?

Second question: you move the same code into a service published with Native AOT. Does the line JsonSerializer.Serialize(order) compile, and does it run?

Third: once the app is warmed up, how much does a JsonSerializerContext produced by source generation speed up each serialization compared to reflection?

In the previous post we looked at the settings and model rules of STJ. This post moves to performance and scale. The examples are in the Bookstore.IO.Json, Bookstore.Api and Bookstore.IO.Benchmarks projects in the series' repo:

dotnet run -c Release --project src/Bookstore.IO.Json -- export 1000000
dotnet run -c Release --project src/Bookstore.IO.Json -- summary stream

Source Generation: JsonSerializerContext and [JsonSerializable]

The first time STJ sees a type, it inspects it with reflection: it finds the properties, the constructor and the attributes, and builds a metadata object (JsonTypeInfo) from them. This happens once per type in every process. Source generation does the same work at compile time; you declare a partial class and the source generator produces the code:

[JsonSourceGenerationOptions(JsonSerializerDefaults.Web, UseStringEnumConverter = true)]
[JsonSerializable(typeof(Order))]
[JsonSerializable(typeof(List<Order>))]
[JsonSerializable(typeof(IAsyncEnumerable<Order>))]
[JsonSerializable(typeof(ExportSummary))]
public partial class BookstoreJsonContext : JsonSerializerContext;

When you use it, you pass the JsonTypeInfo generated by the context instead of options: JsonSerializer.Serialize(order, BookstoreJsonContext.Default.Order). Types inside Order, such as OrderItem and OrderStatus, are included automatically; listing the root types is enough. How source generators work in general is covered in the Source Generator post of the Advanced C# series.

To measure the cost of the first call, we ran each mode in a separate process, five times each:

reflection first call:   19.8 ms,   246,264 bytes, 438 chars
sourcegen  first call:   15.3 ms,   103,608 bytes, 438 chars
reflection first call:   19.5 ms,   245,880 bytes, 438 chars
sourcegen  first call:   15.1 ms,   103,608 bytes, 438 chars
reflection first call:   19.7 ms,   250,336 bytes, 438 chars
sourcegen  first call:   15.0 ms,   103,608 bytes, 438 chars

For the same output, the first call is about 4.5 ms shorter and allocates 140 KB less. That is a small number for a single type; but in an API with hundreds of DTOs, on the first requests of every new pod and in short-lived serverless functions, this cost is paid for each type separately. Most of the remaining 15 ms is JIT compilation; with Native AOT, where there is no JIT either, the startup difference becomes more visible.

Native AOT and trimming

The answer to the second question. In a project with PublishAot on, the reflection-based call compiles, but with two warnings:

warning IL2026: Using member 'System.Text.Json.JsonSerializer.Serialize<TValue>(TValue, JsonSerializerOptions)' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code. JSON serialization and deserialization might require types that cannot be statically analyzed. Use the overload that takes a JsonTypeInfo or JsonSerializerContext, or make sure all of the required types are preserved.
warning IL3050: Using member 'System.Text.Json.JsonSerializer.Serialize<TValue>(TValue, JsonSerializerOptions)' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. JSON serialization and deserialization might require types that cannot be statically analyzed and might need runtime code generation. Use System.Text.Json source generation for native AOT applications.

When you publish with trimming or AOT, the SDK sets JsonSerializerIsReflectionEnabledByDefault to false. We saw what happens then by turning the same switch off by hand in a console app:

AppContext.SetSwitch("System.Text.Json.JsonSerializer.IsReflectionEnabledByDefault", false);
Reflection: InvalidOperationException: Reflection-based serialization has been disabled for this application. Either use the source generator APIs or explicitly configure the 'JsonSerializerOptions.TypeInfoResolver' property.
Source gen: {"orderNumber":"BS-0000001","customerName":"Linus","createdA...

If you ignore the warnings, the error does not come at compile time but at runtime, on the first JSON call. In a project heading for AOT, treat these warnings as errors.

Metadata Mode and Serialization-Optimization Mode

The source generator can produce two things. Metadata mode generates at compile time the JsonTypeInfo that reflection would build at runtime; both serialization and deserialization use it. Serialization-optimization (fast path) mode generates a dedicated method that writes straight to Utf8JsonWriter; it is used only for synchronous serialization and does not support every setting (custom converters, ReferenceHandler, the encoder setting and others). By default both are generated, and when the fast path cannot be used, it falls back to metadata. To answer the third question, we serialized 1,000 orders three ways:

[JsonSourceGenerationOptions(JsonSerializerDefaults.Web, GenerationMode = JsonSourceGenerationMode.Metadata)]
[JsonSerializable(typeof(List<Order>))]
public partial class MetadataOnlyContext : JsonSerializerContext;

[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, GenerationMode = JsonSourceGenerationMode.Serialization)]
[JsonSerializable(typeof(List<Order>))]
public partial class FastPathContext : JsonSerializerContext;

[Benchmark]
public void SourceGenFastPath()
{
    _writer.Reset();
    JsonSerializer.Serialize(_writer, _orders, FastPathContext.Default.ListOrder);
    _writer.Flush();
}
| Method            | Mean     | Error   | StdDev  | Ratio | RatioSD | Gen0   | Allocated | Alloc Ratio |
|------------------ |---------:|--------:|--------:|------:|--------:|-------:|----------:|------------:|
| Reflection        | 389.5 us | 6.73 us | 7.76 us |  1.00 |    0.03 | 9.2773 |   80312 B |        1.00 |
| SourceGenMetadata | 363.8 us | 7.25 us | 8.91 us |  0.93 |    0.03 | 9.2773 |   80312 B |        1.00 |
| SourceGenFastPath | 280.9 us | 2.92 us | 2.73 us |  0.72 |    0.02 |      - |         - |        0.00 |

In a warmed-up app, metadata mode is not meaningfully faster than reflection; in another run of the same benchmark the ratio came out at 1.12, so the difference is within measurement noise. That is expected: metadata built by reflection is also cached after the first call. The gains of metadata mode are at startup and in AOT compatibility. The fast path does make a real difference: the time drops by 28 percent and allocation goes to zero.

We also fell into a trap while trying this. When we selected only Serialization mode and used the same context with the overload that takes a Stream, the call could not use the fast path, and since there was no metadata to fall back to, we got this error:

System.InvalidOperationException: TypeInfoResolver 'Bookstore.IO.Benchmarks.FastPathContext' did not provide property metadata for type 'Bookstore.Ordering.Order'.

The practical takeaway: unless you have a measured reason to change the mode, stay with the default (both). If you want the fast path, verify with a benchmark that the call on the hot path is synchronous and uses no unsupported setting or attribute (for example [JsonConverter]). We separately checked that the context's UseStringEnumConverter setting does not turn off the fast path; you can test any setting you are unsure about the same way, with a small context in Serialization-only mode.

Source Generation in ASP.NET Core

In minimal APIs we add the context to the front of the resolver chain with ConfigureHttpJsonOptions:

var builder = WebApplication.CreateSlimBuilder(args);

builder.Services.ConfigureHttpJsonOptions(options =>
{
    // Source-generated metadata first; types it does not know fall back to the next resolver.
    options.SerializerOptions.TypeInfoResolverChain.Insert(0, BookstoreJsonContext.Default);
});

var app = builder.Build();

// Streams a JSON array: each order is serialized as it is produced, nothing is buffered.
app.MapGet("/api/orders/export", (int count) => ExportOrders(count));

static async IAsyncEnumerable<Order> ExportOrders(int count)
{
    foreach (var order in SampleOrders.Generate(count))
    {
        yield return order;
    }
    await Task.CompletedTask;
}
curl -i "http://localhost:5080/api/orders/export?count=2"
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Server: Kestrel
Transfer-Encoding: chunked

[{"orderNumber":"BS-0000001","customerName":"Linus","createdAtUtc":"2026-11-01T00:00:07Z","status":"Pending","currency":"TRY","items":[{"isbn":"9780141439587","title":"Emma","quantity":1,"unitPrice":189.50,"lineTotal":189.50},...],"total":1116.40},{"orderNumber":"BS-0000002",...}]

Transfer-Encoding: chunked shows the response is sent in pieces without knowing its length up front. Because the endpoint returns IAsyncEnumerable<Order>, STJ writes each order to the response as it arrives; even for a request of 200,000 orders (about 70 MB) the server never collects the whole list in memory. We use TypeInfoResolverChain.Insert(0, ...) so that a type you forgot to add to the context falls back to reflection instead of failing. With AOT, the reflection resolver will not be in the chain, so that type fails anyway; when moving to AOT, add every endpoint type to the context.

DOM Options: JsonDocument, JsonElement and JsonNode

You do not need a C# type for every JSON. If you read two fields from a webhook sent by a payment provider, or modify JSON and pass it on, the DOM APIs fit better:

JsonElement kept;
using (JsonDocument doc = JsonDocument.Parse(webhook))
{
    JsonElement root = doc.RootElement;
    Console.WriteLine($"JsonDocument: {root.GetProperty("event").GetString()} for {root.GetProperty("orderNumber").GetString()}");
    kept = root;
}
Console.WriteLine(kept.GetProperty("event").GetString()); // ObjectDisposedException

JsonNode node = JsonNode.Parse(webhook)!;
node["attempt"] = node["attempt"]!.GetValue<int>() + 1;
node["receivedAtUtc"] = "2026-11-14T09:31:00Z";
node.AsObject().Remove("amount");
Console.WriteLine($"JsonNode: {node.ToJsonString()}");
JsonDocument: payment.captured for BS-0000042
After Dispose: ObjectDisposedException
JsonNode: {"event":"payment.captured","orderNumber":"BS-0000042","attempt":2,"receivedAtUtc":"2026-11-14T09:31:00Z"}

JsonDocument is read-only and fast: it keeps the JSON in a buffer rented from the pool, and JsonElements are lightweight structs that point into that buffer. The price is that disposing is mandatory and a JsonElement cannot outlive its document. If you need to take an element outside the using block, copy it with Clone(). JsonNode (JsonObject, JsonArray, JsonValue), on the other hand, is a mutable tree; you can add and remove fields and change values. It allocates more but needs no dispose. In short: JsonDocument if you only read, JsonNode if you modify, typed deserialization if you have a model with business rules.

Streaming Large JSON

Back to the first question. We wrote 1 million orders as a JSON array; because we passed an IAsyncEnumerable<Order>, no list was built on the write side either:

public static async Task WriteAsync(Stream destination, IAsyncEnumerable<Order> orders, CancellationToken ct = default) =>
    await JsonSerializer.SerializeAsync(destination, orders, BookstoreJsonContext.Default.IAsyncEnumerableOrder, ct);
Wrote 1,000,000 orders, 348,070,221 bytes in 1,888 ms, peak heap 6,852,336 bytes

Then we read the same file three ways and printed, in each process, the largest heap size the GC had seen:

public static async Task<ExportSummary> SummarizeAsync(Stream source, CancellationToken ct = default)
{
    long count = 0;
    decimal revenue = 0;
    var customers = new HashSet<string>();
    await foreach (Order? order in JsonSerializer.DeserializeAsyncEnumerable(source, BookstoreJsonContext.Default.Order, ct))
    {
        count++;
        revenue += order!.Total;
        customers.Add(order.CustomerName);
    }
    return new ExportSummary(count, revenue, customers.Count);
}
list    1,000,000 orders, 888,278,668.60 TRY in 2,770 ms, peak heap 835,936,128 bytes, allocated 960,005,904 bytes
stream  1,000,000 orders, 888,278,668.60 TRY, 6 customers in 1,643 ms, peak heap 7,937,632 bytes, allocated 943,200,488 bytes
reader  888,278,668.60 TRY in 549 ms, peak heap 127,920 bytes, allocated 127,632 bytes

The answer: with List<Order> the heap reached 836 MB, more than twice the file. Every string in the JSON becomes a .NET string (UTF-16, so 2 bytes per character), and every order becomes an object graph. With DeserializeAsyncEnumerable, the same job finished with an 8 MB heap, and faster; total allocation is nearly the same, but since the objects are born and die right away, the GC collects them cheaply. If the file were 10 times larger, the list approach would crash, while the stream approach would keep running with the same 8 MB.

Utf8JsonReader: when you need it

The third row in the table is the lowest-level path. Utf8JsonReader is a ref struct that reads JSON token by token; it creates no objects. We only summed the lineTotal values:

public static decimal SumLineTotals(Stream source)
{
    byte[] buffer = ArrayPool<byte>.Shared.Rent(64 * 1024);
    try
    {
        int filled = source.Read(buffer);
        var state = new JsonReaderState();
        decimal sum = 0;
        bool expectLineTotal = false;

        while (true)
        {
            bool isFinal = filled < buffer.Length && source.Position == source.Length;
            var reader = new Utf8JsonReader(buffer.AsSpan(0, filled), isFinal, state);

            while (reader.Read())
            {
                if (reader.TokenType == JsonTokenType.PropertyName)
                {
                    expectLineTotal = reader.ValueTextEquals("lineTotal"u8);
                }
                else if (expectLineTotal && reader.TokenType == JsonTokenType.Number)
                {
                    sum += reader.GetDecimal();
                    expectLineTotal = false;
                }
            }

            if (isFinal) return sum;

            // Keep the unread tail, read more after it.
            state = reader.CurrentState;
            int consumed = (int)reader.BytesConsumed;
            int leftover = filled - consumed;
            buffer.AsSpan(consumed, leftover).CopyTo(buffer);
            if (leftover == buffer.Length)
            {
                throw new InvalidDataException("A single JSON token is larger than the buffer.");
            }
            filled = leftover + source.Read(buffer.AsSpan(leftover));
        }
    }
    finally
    {
        ArrayPool<byte>.Shared.Return(buffer);
    }
}

Three times faster, with next to no allocation. But almost all of the code is buffer bookkeeping: isFinalBlock, resuming with JsonReaderState, moving a token left half-read at the end of the buffer to the front. It is the same discussion as Pipelines in the third post. Also, this code would add lineTotal even if it saw that name on another object; code that does not know the structure can silently produce wrong results when the schema changes. Use Utf8JsonReader for a measured bottleneck where you need to pull a single field out of very large data; in every other case DeserializeAsyncEnumerable is enough. Its counterpart on the write side is Utf8JsonWriter; we saw it as the target of the fast path in the previous benchmark.

Generating a schema with JsonSchemaExporter

JsonSchemaExporter, added in .NET 9, returns how a type will be serialized under given options as a JSON Schema. It is handy for documenting the export format for the accounting team or sending a contract to a partner:

JsonNode schema = JsonSchemaExporter.GetJsonSchemaAsNode(JsonSerializerOptions.Web, typeof(OrderItem));
{
  "type": [
    "object",
    "null"
  ],
  "properties": {
    "isbn": {
      "type": "string"
    },
    "title": {
      "type": "string"
    },
    "quantity": {
      "type": [
        "string",
        "integer"
      ],
      "pattern": "^-?(?:0|[1-9]\\d*)$"
    },
    "unitPrice": {
      "type": [
        "string",
        "number"
      ],
      "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$"
    },
    "lineTotal": {
      "type": [
        "string",
        "number"
      ],
      "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$"
    }
  },
  "required": [
    "isbn",
    "title",
    "quantity",
    "unitPrice"
  ]
}

The schema mirrors the options exactly: because the Web settings accept numbers in quotes, quantity can be either "string" or "integer". The constructor parameters made it into the required list, the computed lineTotal did not. Remember that the schema says "our API accepts this"; if you want a stricter contract, tighten the options first.

Migrating from Newtonsoft.Json

The most common differences when moving an old project to STJ:

Newtonsoft.JsonSystem.Text.Json equivalentNote
JsonConvert.SerializeObjectJsonSerializer.SerializeCache the options instance
[JsonProperty("name")][JsonPropertyName("name")]Attributes live in a different namespace
Case-insensitive matching (default)PropertyNameCaseInsensitive or JsonSerializerOptions.WebSTJ default is case-sensitive
Tolerates comments and trailing commasReadCommentHandling.Skip, AllowTrailingCommasSTJ default rejects them
ReferenceLoopHandling.IgnoreReferenceHandler.IgnoreCyclesPreserve gives $id/$ref too
JObject, JArray, JTokenJsonNode, JsonObject, JsonArrayJsonDocument for read-only
TypeNameHandling[JsonPolymorphic] and [JsonDerivedType]Only allowed types
DefaultValueHandling.IgnoreJsonIgnoreCondition.WhenWritingDefaultWhenWritingNull for null

The comment and trailing comma difference shows up immediately in hand-edited configuration files or files produced by an old ERP:

Web: '/' is invalid after a value. Expected either ',', '}', or ']'. Path: $ | LineNumber: 1 | BytePositionInLine: 2.
Lenient: OrderItem { Isbn = 9780441172719, Title = Dune, Quantity = 2, UnitPrice = 349.90, LineTotal = 699.80 }

Do not migrate in one go. First collect a "golden" JSON set from real requests and responses of the existing endpoints; verify with tests that both serializers produce the same JSON from the same object and read the same object from the same JSON. Then migrate module by module: in MVC controllers, AddNewtonsoftJson can keep the old behavior while new endpoints start with STJ. The part that usually takes longest is custom JsonConverters and dynamic code written with JObject; list them and plan each separately.

In Production

Never deserialize large data into a List<T>. For exports and imports, use DeserializeAsyncEnumerable, and IAsyncEnumerable<T> on the write side. The difference in the table exceeded 100x at 1 million orders; with a 512 MB container memory limit, the list approach would not have finished at all.

Put size and depth limits on JSON coming from users. Kestrel's request body limit (about 30 MB by default) is the first line of defense; STJ's MaxDepth setting (64 by default) protects against overly nested JSON.

Use source generation as a build check. A type missing from the context fails at runtime under AOT; the cheapest way to catch that is a CI step that builds with trimming warnings treated as errors.

Measure with your own data. The numbers in this post come from a developer machine; absolute values will differ on your hardware, but the ratios (836 MB against 8 MB, zero allocation on the fast path) stay similar.

Common Pitfalls

Not disposing JsonDocument. The buffer rented from the pool never goes back; under load, memory use creeps up. Always using.

Letting a JsonElement outlive its document. An element handed to a cache or another task throws ObjectDisposedException once the document closes. Use Clone() if needed.

Forgetting to add wrapper types to the context. Wrapper types such as IAsyncEnumerable<Order> or List<Order> need their own [JsonSerializable]; if missing, the call falls back to reflection or fails under AOT.

Choosing only Serialization mode. On the first call that cannot use the fast path there is no metadata to fall back to, so you get an exception. Stay with the default mode or measure which calls use the fast path.

Treating Utf8JsonReader as the answer to everything. It is fast, but buffer management and knowledge of the structure are your responsibility. Without a measured bottleneck, typed streaming deserialization is safer.

A Small Experiment

★☆☆ Add a JsonNode mode to the summary command: read the file with JsonNode.Parse and compute revenue. Compare its peak heap with list and stream.

★★☆ Remove UseStringEnumConverter from BookstoreJsonContext and put [JsonConverter(typeof(JsonStringEnumConverter<OrderStatus>))] on OrderStatus instead. Measure how this change affects the fast path row of the benchmark.

★★★ Change SumLineTotals so that it only counts lineTotal values inside the items array: track the level with reader.CurrentDepth. To test it, add a fake "lineTotal": 1000000 field to the root of an order and see the old code's wrong result.

That is it for JSON; the next post moves to XML. Streaming reads with XmlReader, LINQ to XML, the difference between XmlSerializer and DataContractSerializer, namespaces and XSD validation, and finally secure defaults against XXE: we will see all of them on a UBL-like invoice document.