Post

System.Text.Json Source Generators vs Newtonsoft.Json: A BenchmarkDotNet Comparison on .NET 8

Benchmark Newtonsoft.Json against System.Text.Json with reflection and source generators. Serialization drops from 2,140 ns to 588 ns with zero allocations.

System.Text.Json Source Generators vs Newtonsoft.Json: A BenchmarkDotNet Comparison on .NET 8

Most .NET web services spend a surprising share of their CPU turning objects into JSON and back. After allocations in hot loops and EF Core query shape, serialization is the third place I look when an API is slower than it should be, and it is usually the cheapest to fix: the code change is a [JsonSerializable] attribute and a partial class.

This post benchmarks the same 1 KB Order payload four ways with BenchmarkDotNet on .NET 8: Newtonsoft.Json 13.0.3, System.Text.Json with the default reflection-based metadata, System.Text.Json with a source-generated JsonSerializerContext, and the source generator combined with a pooled Utf8JsonWriter. As always, the absolute numbers are from one laptop; the ratios are what carry over.

Bar chart of mean serialization time per 1 KB order across Newtonsoft.Json, System.Text.Json reflection and source generators

The payload

A typical order document: a header, a customer and five lines. Nothing exotic, which is the point. This is the shape most enterprise APIs move around all day.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public sealed class Order
{
    public int Id { get; set; }
    public DateTime PlacedAt { get; set; }
    public string Currency { get; set; } = "USD";
    public Customer Customer { get; set; } = new();
    public List<OrderLine> Lines { get; set; } = new();
}

public sealed class Customer
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public string Email { get; set; } = "";
}

public sealed class OrderLine
{
    public string Sku { get; set; } = "";
    public int Quantity { get; set; }
    public decimal UnitPrice { get; set; }
}

Serialized with indentation off, one order is about 1,000 bytes of UTF-8.

Step 0: Newtonsoft.Json (the baseline)

1
2
3
4
5
6
7
8
9
10
11
12
private static readonly JsonSerializerSettings NewtonsoftSettings = new()
{
    ContractResolver = new CamelCasePropertyNamesContractResolver()
};

[Benchmark(Baseline = true)]
public string Serialize_Newtonsoft() =>
    JsonConvert.SerializeObject(_order, NewtonsoftSettings);

[Benchmark]
public Order Deserialize_Newtonsoft() =>
    JsonConvert.DeserializeObject<Order>(_json, NewtonsoftSettings)!;

Newtonsoft is a string-first library: it writes to a StringWriter, so every call allocates the intermediate StringBuilder, the final string, and then the UTF-8 bytes once ASP.NET copies it to the response. That shows up in the Allocated column as roughly 6 KB per 1 KB payload.

Step 1: System.Text.Json with reflection

1
2
3
4
5
6
7
8
9
private static readonly JsonSerializerOptions StjOptions = new(JsonSerializerDefaults.Web);

[Benchmark]
public byte[] Serialize_Stj_Reflection() =>
    JsonSerializer.SerializeToUtf8Bytes(_order, StjOptions);

[Benchmark]
public Order Deserialize_Stj_Reflection() =>
    JsonSerializer.Deserialize<Order>(_jsonBytes, StjOptions)!;

Two changes buy the 2x: System.Text.Json works in UTF-8 end to end, so there is no UTF-16 round trip, and the JsonSerializerOptions instance caches per-type metadata after the first call. Note that the options object must be reused; constructing a new JsonSerializerOptions per call throws the metadata cache away and is one of the most common ways to make System.Text.Json slower than Newtonsoft.

Step 2: Source generator

1
2
3
4
5
6
7
8
9
10
11
12
13
[JsonSourceGenerationOptions(
    PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
    GenerationMode = JsonSourceGenerationMode.Default)]
[JsonSerializable(typeof(Order))]
internal partial class OrderJsonContext : JsonSerializerContext { }

[Benchmark]
public byte[] Serialize_Stj_SourceGen() =>
    JsonSerializer.SerializeToUtf8Bytes(_order, OrderJsonContext.Default.Order);

[Benchmark]
public Order Deserialize_Stj_SourceGen() =>
    JsonSerializer.Deserialize(_jsonBytes, OrderJsonContext.Default.Order)!;

The generator emits the property metadata and, in Serialization mode, a hand-unrolled Write method for each type at compile time. Nothing is discovered through reflection at runtime, which removes the first-call warm-up (important for serverless and scale-to-zero), makes the code trimmable and Native AOT compatible, and shaves another 30% off the steady-state time because the generated writer skips the generic converter dispatch.

In ASP.NET Core minimal APIs you wire it in once:

1
2
builder.Services.ConfigureHttpJsonOptions(o =>
    o.SerializerOptions.TypeInfoResolverChain.Insert(0, OrderJsonContext.Default));

Step 3: Source generator plus a pooled Utf8JsonWriter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private readonly ArrayBufferWriter<byte> _buffer = new(2048);
private readonly Utf8JsonWriter _writer;

public JsonBenchmarks()
{
    _writer = new Utf8JsonWriter(_buffer, new JsonWriterOptions { SkipValidation = true });
}

[Benchmark]
public int Serialize_Stj_SourceGen_Utf8()
{
    _buffer.ResetWrittenCount();
    _writer.Reset(_buffer);
    JsonSerializer.Serialize(_writer, _order, OrderJsonContext.Default.Order);
    return _buffer.WrittenCount;   // bytes are in _buffer, ready for the response stream
}

This is what Kestrel effectively does for you when you return an object from an endpoint: it serializes straight into the response PipeWriter. Doing it explicitly matters when you own the transport, for example writing to a message bus, a cache, or a file. The byte[] result disappears, and with it the last allocation.

The results

BenchmarkDotNet console output comparing Newtonsoft.Json, System.Text.Json reflection and System.Text.Json source generators for serialize and deserialize, with source-generated rows highlighted BenchmarkDotNet output on .NET 8.0.8. The source-generated rows are highlighted; Alloc Ratio is the column that explains the GC behaviour under load.

MethodMeanRatioGen0AllocatedAlloc Ratio
Serialize_Newtonsoft2,140.3 ns1.000.73246136 B1.00
Serialize_Stj_Reflection1,079.8 ns0.500.15451296 B0.21
Serialize_Stj_SourceGen742.1 ns0.350.15451296 B0.21
Serialize_Stj_SourceGen_Utf8588.4 ns0.27-0 B0.00
Deserialize_Newtonsoft3,412.7 ns1.001.08349072 B1.00
Deserialize_Stj_Reflection1,655.2 ns0.490.16211360 B0.15
Deserialize_Stj_SourceGen1,298.6 ns0.380.16211360 B0.15

Three observations:

  • Reflection to source generator is a 30% speed-up, not a 10x one. The big step is leaving Newtonsoft. If you are already on System.Text.Json with a cached options instance, the generator is mostly a startup, trimming and AOT story with a modest steady-state bonus.
  • Deserialization allocations are the object graph itself. The 1,360 bytes in the System.Text.Json deserialize rows are the Order, Customer and five OrderLine instances plus the strings; the serializer adds nothing. Newtonsoft’s extra 7.7 KB is the JToken-style intermediate buffers and the UTF-16 string.
  • Zero allocation serialization is achievable with no unsafe code. Pool the writer and buffer per request (or let Kestrel do it) and the GC never hears about your JSON.

Pitfalls when migrating

  • System.Text.Json is case-sensitive by default outside of JsonSerializerDefaults.Web; Newtonsoft is not. Use the Web defaults or set PropertyNameCaseInsensitive = true.
  • Fields, non-public setters and parameterised constructors need opt-in (IncludeFields, [JsonInclude], [JsonConstructor]). Newtonsoft handles most of these silently.
  • DateTime is written as ISO 8601 with no DateTimeKind adjustments; Newtonsoft also writes ISO 8601 but the two differ on Unspecified kinds. Compare payloads in a golden-file test before switching a public contract.
  • The source generator cannot see types that are only reachable through object or dynamic; add them to the context explicitly or they fall back to a runtime NotSupportedException.

Takeaways

  • Moving from Newtonsoft.Json to System.Text.Json roughly halves serialization time and cuts allocations by 80%; a single shared JsonSerializerOptions is mandatory to get there.
  • A JsonSerializerContext adds another ~30%, removes first-call reflection cost, and makes the service trimmable and Native AOT ready.
  • Serialize into a pooled Utf8JsonWriter (or let ASP.NET Core do it) to reach zero allocations per payload.
  • Measure with BenchmarkDotNet and dotnet-counters on your own payload shape before and after; the ratios above are typical, the nanoseconds are not.

The serializer is usually the last of three stops on the same [MemoryDiagnoser] tour. The two posts below use the same BenchmarkDotNet workflow on the layers that feed it:

This post is licensed under CC BY 4.0 by the author.