Dev.to WebDev 🛠 Dev 👁 0 📖 4 min read

A New JsonSerializerOptions Every Call Cost Me 120x

A list endpoint on one of my services started showing up heavier in the traces than it had any right to be. It pulled a small object, serialized it, returned it. The payload was tiny — an order with three line items. But

A list endpoint on one of my services started showing up heavier in the traces than it had any right to be. It pulled a small object, serialized it, returned it. The payload was tiny — an order with three line items. But under load the thing allocated like it was streaming a video, and the CPU time didn't match the shape of the work. I went looking for the expensive query. There wasn't one. The expensive part was the return.

Here's the line I'd written, more or less, inside the handler:

var json = JsonSerializer.Serialize(order, new JsonSerializerOptions
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
    Converters = { new JsonStringEnumConverter() },
});

Looks fine. It is not fine. That new JsonSerializerOptions { ... } sitting inside a method that runs on every request is the whole bug.

What the options object actually is

I used to think JsonSerializerOptions was a settings bag — a few flags, a naming policy, done. It isn't. The first time you serialize with a given options instance, System.Text.Json walks your type with reflection, builds the metadata it needs to write it (the contract for Order, for Line, the converters, the property names), caches all of that on that options instance, and then freezes the instance so it can't change underneath the cache. That setup is not cheap, and it's the reason the reused path is fast: you pay once, then every serialize after that rides the warm cache.

Build a new options object every call and you throw that cache away every call. Fresh reflection, fresh contracts, fresh everything, two hundred thousand times. You're not configuring a serializer. You're constructing one from scratch in your hot path.

Making it admit the cost

I didn't want to argue from a profiler's hunch, so I ran both shapes side by side. Same order, same settings, serialized 200,000 times. One path news up the options each iteration. The other builds it once and reuses it. I measured wall time and total allocated bytes for each. This is a container, not a lab bench, so I care about the ratio far more than the raw milliseconds.

static JsonSerializerOptions BuildOptions() => new()
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
    Converters = { new JsonStringEnumConverter() },
};

var cached = BuildOptions();

// warm both paths' first-use cost before timing
_ = JsonSerializer.Serialize(order, cached);
_ = JsonSerializer.Serialize(order, BuildOptions());

// Path A: a brand-new options object on every call
var before = GC.GetTotalAllocatedBytes(precise: true);
var sw = Stopwatch.StartNew();
for (int i = 0; i < 200_000; i++)
{
    var opts = BuildOptions();
    _ = JsonSerializer.Serialize(order, opts);
}
sw.Stop();
var aMs = sw.Elapsed.TotalMilliseconds;
var aAlloc = GC.GetTotalAllocatedBytes(precise: true) - before;

// Path B: one cached instance, reused
before = GC.GetTotalAllocatedBytes(precise: true);
sw.Restart();
for (int i = 0; i < 200_000; i++)
    _ = JsonSerializer.Serialize(order, cached);
sw.Stop();
var bMs = sw.Elapsed.TotalMilliseconds;
var bAlloc = GC.GetTotalAllocatedBytes(precise: true) - before;

The order itself is nothing special — a record with an enum status and a list of three lines, the kind of thing any API hands back:

record Order(Guid Id, string Customer, OrderStatus Status, List<Line> Lines);
record Line(string Sku, int Quantity, decimal UnitPrice);
enum OrderStatus { Pending, Shipped, Delivered, Cancelled }

Here's what printed, on .NET 10, Release build:

Serializing the same order 200,000 times.

new options each call : 17,402.5 ms    7,021.9 MB
one cached instance   :    146.0 ms      166.3 MB

time   : ~120x slower when rebuilt each call
memory :  ~42x more allocated

A hundred and twenty times slower. Seven gigabytes of allocation to produce the exact same JSON string that the cached path produced in a hundred and sixty-six megabytes. I ran it twice to be sure the numbers held, and they did, within a few percent. That's not a tuning opportunity. That's a serializer rebuilding its entire type model, over and over, for no reason.

The fix is embarrassingly small

Move the options out of the method. Build it once, hang it off a static field, reuse it forever.

static readonly JsonSerializerOptions JsonOpts = new()
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
    Converters = { new JsonStringEnumConverter() },
};

// in the handler
var json = JsonSerializer.Serialize(order, JsonOpts);

That's the entire change. The instance is immutable once it's been used anyway, so sharing it across every request and every thread is exactly what it's built for — JsonSerializer reads from a frozen options object concurrently without complaint.

If your settings really do vary per call, and sometimes they genuinely do, don't fall back to newing one up each time. Cache the handful of variants you actually use, keyed by whatever differs. In practice most codebases have two or three distinct option sets total, not one per request. And if you're on the default settings with no customization at all, JsonSerializer.Serialize(order) already uses a cached internal instance, so the parameterless call was never the problem — it's specifically the configured-options-inline pattern that bites.

One honest caveat on the number: 120x is what this payload and this machine gave me. A bigger, deeper object graph makes the per-call rebuild even more lopsided because there's more metadata to reconstruct. A trivial payload narrows the gap. But I've never seen reuse lose, and the allocation story is one-directional regardless — a fresh options object is always garbage you didn't need to make.

I keep finding these in code reviews now that I know the shape. Any new JsonSerializerOptions that isn't assigned to a static readonly field is worth a second look. Nine times out of ten it's sitting in a method that runs a lot.

Where's yours hiding? Drop a line if you find one in a hot path — I'd bet money it's in a serialization helper somebody wrote once and never profiled.

— still benchmarking things nobody asked me to profile

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.