A (performance) quirk with JsonSerializer

15/02/2023
C#.NET

System.Text.Json.JsonSerializer has a weird quirk in regard to performance and memory management. So we will discuss what is "wrong" with this code: JsonSerializer.Serialize(myObject, new JsonSerializerOptions(...));.

JsonSerializerOptions

The JsonSerializerOptions is used to customize how the JsonSerializer serializes (your C# object to a string for example) and deserializes objects (string back to your C# object). It basically consists of a few properties that describe, for example, whether or not you want to have indentation (so whitespaces to make it easier to read for humans, but takes more space) and so on. Now what does it have to do with the code snippet above? Let's have a small benchmark:

Benchmark

[MemoryDiagnoser]
public class JsonBenchmark
{
    private const int Iterations = 100;

    private static readonly JsonSerializerOptions _options = new();

    [Benchmark(Baseline = true)]
    public void SerializeDefault()
    {
        for (var i = 0; i < Iterations; i++)
            JsonSerializer.Serialize(new Test("Steven", "Giesel"));
    }

    [Benchmark]
    public void SerializeOptionsField()
    {
        for (var i = 0; i < Iterations; i++)
            JsonSerializer.Serialize(new Test("Steven", "Giesel"), _options);
    }

    [Benchmark]
    public void SerializeOptionsNew()
    {
        for (var i = 0; i < Iterations; i++)
            JsonSerializer.Serialize(new Test("Steven", "Giesel"), new JsonSerializerOptions());
    }
}

public sealed record Test(string FirstName, string LastName);

Results:

|                Method |     Mean |    Error |   StdDev | Ratio | RatioSD |   Gen0 |   Gen1 |   Gen2 | Allocated | Alloc Ratio |
|---------------------- |---------:|---------:|---------:|------:|--------:|-------:|-------:|-------:|----------:|------------:|
|      SerializeDefault | 13.89 us | 0.083 us | 0.074 us |  1.00 |    0.00 | 2.2888 |      - |      - |  14.06 KB |        1.00 |
| SerializeOptionsField | 13.89 us | 0.032 us | 0.030 us |  1.00 |    0.01 | 2.2888 |      - |      - |  14.06 KB |        1.00 |
|   SerializeOptionsNew | 53.36 us | 0.781 us | 0.692 us |  3.84 |    0.05 | 6.7139 | 0.2441 | 0.1221 |  42.79 KB |        3.04 |

We can see that if you new up an instance in a loop, it can get costly. And you might think: Of course - the new() itself takes time and memory. Yes and no. Yes, that is true, but the no not that huge amount we can see here. Keep in mind I only used 100 iterations. No there is a complete different reason:

Caching

The way Serialize and Deserialize works are as follows:

public static string Serialize<TValue>(TValue value, JsonSerializerOptions? options = null)
{
    JsonTypeInfo<TValue> jsonTypeInfo = GetTypeInfo<TValue>(options);
    return WriteString(value, jsonTypeInfo);
}

If we dig deeper:

private static JsonTypeInfo GetTypeInfo(JsonSerializerOptions? options, Type inputType)
{
    Debug.Assert(inputType != null);

    options ??= JsonSerializerOptions.Default;

    if (!options.IsInitializedForReflectionSerializer)
    {
        options.InitializeForReflectionSerializer();
    }

If options is not set (like in our baseline case) it uses a default instance. If we pass in our static field it behaves roughly the same. So basically options.IsInitializedForReflectionSerializer this will evaluate to true in those two cases and we don't have to go into InitializeForReflectionSerializer a second time. Only in our first call.

And here is the difference to the last case (new()), because here we will call the InitializeForReflectionSerializer every single time. This function is quite expensive as it builds up the necessary information on how your object looks like via reflection. That is where the cost comes from.

Should I care?

You can basically ignore all this if you don't pass in your own JsonSerializerOptions. Also if you call that method only once or a few times you can ignore it to some extent. But you can use JsonSerializer.Serialize in combination with Entity Framework in combination with ValueConverter:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder
        .Entity<MyModel>()
        .Property(e => e.MyList)
        .HasConversion(
            v => JsonSerializer.Serialize(v, new JsonSerializerOptions(...))
            v => JsonSerializer.Deserialize<List<MyType>>(v, new JsonSerializerOptions(...)));
}

If you have thousands of rows, that can be very expensive! Better to take either the default (so pass in null) or have a static field holding that information for you.

6
An error has occurred. This application may no longer respond until reloaded. Reload x