Leverage the System.Text.Json source generator for a considerable performance boost.

I’ve been a big fan of .NET 5 Source generators to automatically generate patterns for classes and analyse code for rule compliance for a while now. If you aren’t sure what code generators bring to .NET development, you can read my blog post here. In .NET 6.0, though, Microsoft brings something even more exciting to the table. The new C# source generators in .NET 6 provide a massive improvement in any applications that use JSON. For many years, Json.NET, developed by James Newton-King has been the defacto standard in Serializing and Deserializing JSON, but if you haven’t had a look at the new System.Text.Json APIs, I would recommend you do.


Serialising and Deserializing JSON has traditionally relied on the existing model, which is backed by runtime reflection. Reflection comes with a performance cost while deserialising JSON strings into .NET objects which is a problem for start-up, memory usage, and assembly trimming. Given that the standard exchange format with servers generally being JSON, there are a plethora of JSON parsers and serialisers available but System.Text.Json provides new JSON APIs that are optimised for performance by using Span<T> and can process UTF-8 directly without having to transcode to UTF-16 string instances. Both these aspects are critical for ASP.NET Core, where throughput is an essential requirement.


Things just took a giant leap forward, though with the introduction of a new source generator as part of System.Text.Json. The JSON source generator’s work in conjunction with JsonSerializer, and can be configured in multiple ways, as shown below.

For example, let’s create a simple Person type to serialise.


public class Person

{

public long Id { get; set; }

public string FirstName { get; set; }

public string LastName { get; set; }

public string Email { get; set; }

public string Phone { get; set; }

public DateTime BirthDate { get; set; }

public string StreetAddress { get; set; }

public string City { get; set; }

public string County { get; set; }

public string Country { get; set; }

public string PostalCode { get; set; }

public double Latitude { get; set; }

public double Longitude { get; set; }

public bool Active { get; set; }

public DateTime LastLogin { get; set; }

}


With System.Text.Json, Microsoft was able to gain around 1.3x the speed, and the serialiser can read and write JSON asynchronously and is optimised for UTF-8 text, making it ideal for REST API and back-end applications. A pretty huge performance improvement and clean DRY code.


public string Serialize(Person value)

{

return JsonSerializer.Serialize(value);

}


Now let’s look at leveraging the compile-time source generation in .NET 6 to generate C# source files that can be compiled as part of the application build. The approach the JSON source generator takes is to move the runtime inspection of JSON-serializable types to compile-time, where it generates a static model to access data on the types, with optimised serialisation logic using the Utf8JsonWriter directly.


By defining a partial class that derives from a new class called JsonSerializerContext, and indicate serialisable types using a new JsonSerializableAttribute class.


For example, given a simple Person type to serialise, part of the build will use the source generator to augment the PersonSerializerContext partial class.


[JsonSerializable(typeof(Person))]

[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,

WriteIndented = false)]

public partial class PersonSerializerContext : JsonSerializerContext

{


}


[JsonSerializable(typeof(IEnumerable<Person>))]

[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,

WriteIndented = false)]

public partial class PeopleSerializerContext : JsonSerializerContext

{


}


To see the generated code, add the following to your project file.


<PropertyGroup>

<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>

<CompilerGeneratedFilesOutputPath>GeneratedFiles</CompilerGeneratedFilesOutputPath>

</PropertyGroup>


<ItemGroup>

<Compile Remove="$(CompilerGeneratedFilesOutputPath)/*/**/*.cs" />

</ItemGroup>


This allows you to inspect the generated code instead of reflection to serialise and deserialise your class.




This generated code is then integrated into the compiling application and allows you to pass it directly to the new overloads in the JsonSerializer, as shown below.


Person person = new() { FirstName = "James", LastName = "Melvin" };

byte[] utf8Json = JsonSerializer.SerializeToUtf8Bytes(person, PersonSerializerContext.Default.Person);

person = JsonSerializer.Deserialize(utf8Json, PersonSerializerContext.Default.Person);


Of course, the long-winded moral of the story is what does this mean for application performance when dealing with serialisation and deserialisation of JSON in .NET 6. The System.Text.Json source generator documentation explains that this boosts performance by removing the warm-up phase by shifting the runtime inspection of serialisable types using reflection to compile-time. The result of this inspection is that we already have source code that initialises instances of this structured serialisation metadata. The generator can generate highly-optimised serialisation logic that honours the serialisation features specified ahead of time.


As such I created a simple benchmarks test project using BenchmarkDotNet, to try to understand what the performance enhancements brought about by leveraging the new System.Text.Json. Serialisation.JsonSerializerContext code generator means in the real world.


The benchmark creates 1000 random Person objects using the previous System.Text.Json serialisation and deserialisation methods as the base and then compares the gain to what pre-generating and using optimised serialisation logic provides. What is the expected increased performance over using JsonSerializer’s robust serialisation logic? Given our Person type, we observed that serialisation is around 30% faster when using the source generator.


If we look at the performance benchmarks where I created a list containing 1000 people (Github), the performance improvement is just over 23% faster.


The gain in my benchmarks for deserialising wasn’t as pronounced but is still a respectable 6.18 % faster. The more complex your JSON, the better your performance gains will be.


When creating Rest API’s though, you don’t need to worry about deserialisation. As such, you can change the serialisation method to serialisation only.


[JsonSerializable(typeof(IEnumerable<Person>))]

[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Serialization,

PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,

WriteIndented = false)]

public partial class PeopleSerializerContext : JsonSerializerContext

{


}


This gives you a vast 29.88% performance improvement. This improved serialisation throughput in an app leads to increased performance over using JsonSerializer’s already robust serialisation logic. Moving the retrieval of the type metadata from runtime to compile-time also means that there is less work for the serialiser to do on start-up. This leads to a reduction in the amount of time it takes to perform the first serialisation or deserialisation of each type as well.