A code review comment is what started this. Someone had wrapped a JsonSerializer.Serialize call in a small helper so every call site got the same camelCase, enum-as-string, ignore-nulls behavior. Clean. Readable. I'd have written it the same way. Then I ran it in a loop and watched the allocation graph go vertical.
The helper looked like this:
static string Serialize(Order order)
{
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() },
};
return JsonSerializer.Serialize(order, options);
}
Spot the problem? I didn't either, not at a glance. The options object gets built fresh on every call. And a JsonSerializerOptions is not the cheap little bag of flags it looks like.
Why a fresh options object is expensive
The first time you serialize with a given JsonSerializerOptions instance, System.Text.Json builds and caches the metadata it needs for every type in the graph: how to read each property, which converter handles each member, the naming policy mapping, all of it. That work is meant to happen once per options instance and then get reused for the lifetime of the instance.
So when you new up the options on every call, you throw that cache away every call. Each serialize pays the full metadata build again, for Order, for Line, for the enum, for everything. You're not reusing anything. You're rebuilding the engine every time you want to drive to the shop.
What it actually costs
I wanted a number, not a hunch. So I serialized one small order (a Guid, a customer name, three line items, an enum, a nullable note, about 323 bytes of JSON) one hundred thousand times, two ways. One with the fresh-options helper above, one with a single cached instance reused across every call. Same options, same output, byte for byte.
Conditions, because they matter: .NET 10, Release build, a small Linux container (not a beefy workstation), median of 9 rounds after a warmup. Allocations come from GC.GetAllocatedBytesForCurrentThread. It's not a lab and I'm not pretending the absolute milliseconds would match yours. I care about the ratio, and the ratio was brutal.
[A: new JsonSerializerOptions() every call]
median= 9,336.3 ms allocated=3,900.4 MB/round
[B: one cached static JsonSerializerOptions]
median= 171.3 ms allocated= 96.9 MB/round
Roughly sixty times slower. And a round of the fresh-options version allocated 3.9 gigabytes to produce 32 MB of actual JSON. The cached version allocated about 97 MB for the identical output. Per call, that's the difference between ~40 KB of garbage and ~1 KB. On a hot endpoint serializing a response per request, that extra 39 KB per call is pure collector pressure, and the collector's bill doesn't land on the request that made the mess. It lands on whichever request is unlucky enough to be running during the next gen-2 pause. That's what makes this so miserable to diagnose from the outside: the symptom shows up nowhere near the cause.
The fix is almost insultingly small. Build the options once and hand the same instance to every call:
static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() },
};
static string Serialize(Order order) => JsonSerializer.Serialize(order, Options);
That's the whole change. A static readonly instead of a local. Sixty times faster, forty times less garbage, zero behavior difference.
The part that makes it sneaky
Here's what I think is the real trap, and it's not the perf. It's that the slow version reads better. The helper that builds options inline looks tidy and self-contained — everything it needs is right there, no shared mutable state to reason about. A reviewer skims it and nods. The fast version has a static field sitting off to the side, which is exactly the kind of thing we've all been trained to be a little suspicious of. The habit that keeps your code clean is the habit that quietly makes this one slow.
One more wrinkle worth knowing: once you serialize or deserialize with an options instance, it becomes read-only. You can't mutate it afterward. That's not a gotcha, it's the signal — the framework freezes it precisely because it's about to cache a pile of metadata against it and expects you to keep using it. The design is telling you to reuse the thing. I spent years not listening.
When does the fresh-options version not matter? If you serialize a handful of times over a process's life (a one-shot config read at startup, a CLI that writes one file and exits), the rebuild cost is real but you'll never feel it, and inlining the options can genuinely be the more readable call. The cliff only bites when the serialize is in a loop or on a request path. But that describes most web code, which is why I'd default to a cached instance everywhere and only inline it when I've got a specific reason.
The whole thing, runnable
Drop this in a net10.0 console project and dotnet run -c Release. No packages. (I usually push these samples to a repo, but my pipeline couldn't reach GitHub this run, so here's the lot inline.)
using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Serialization;
var order = new Order(
Id: Guid.Parse("4a1c0b2e-9f3d-4e7a-8c21-0d5e6f7a8b90"),
Customer: "Acme Logistics",
Placed: new DateTimeOffset(2026, 10, 19, 8, 11, 0, TimeSpan.Zero),
Status: OrderStatus.Processing,
Lines:
[
new Line("WIDGET-01", 4, 12.50m),
new Line("GASKET-77", 20, 0.95m),
new Line("BOLT-M6", 500, 0.03m),
],
Notes: "Leave at dock 3. Call on arrival.");
const int Iterations = 100_000;
const int Rounds = 9;
var cached = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() },
};
_ = SerializeFresh(order); // warm up JIT + metadata build
_ = JsonSerializer.Serialize(order, cached);
Console.WriteLine($"Serializing one order {Iterations:N0} times, median of {Rounds} rounds.\n");
Measure("A: new JsonSerializerOptions() every call", () =>
{
long bytes = 0;
for (int i = 0; i < Iterations; i++) bytes += SerializeFresh(order).Length;
return bytes;
});
Measure("B: one cached JsonSerializerOptions", () =>
{
long bytes = 0;
for (int i = 0; i < Iterations; i++) bytes += JsonSerializer.Serialize(order, cached).Length;
return bytes;
});
static string SerializeFresh(Order order)
{
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() },
};
return JsonSerializer.Serialize(order, options);
}
static void Measure(string label, Func<long> work)
{
var times = new double[Rounds];
long allocated = 0;
for (int r = 0; r < Rounds; r++)
{
long before = GC.GetAllocatedBytesForCurrentThread();
var sw = Stopwatch.StartNew();
work();
sw.Stop();
allocated = GC.GetAllocatedBytesForCurrentThread() - before;
times[r] = sw.Elapsed.TotalMilliseconds;
}
Array.Sort(times);
Console.WriteLine($"[{label}]\n median={times[Rounds / 2],8:N1} ms allocated={allocated / 1024.0 / 1024.0,7:N1} MB/round\n");
}
record Order(Guid Id, string Customer, DateTimeOffset Placed, OrderStatus Status, IReadOnlyList<Line> Lines, string? Notes);
record Line(string Sku, int Qty, decimal UnitPrice);
enum OrderStatus { Pending, Processing, Shipped, Cancelled }
If you've got a System.Text.Json call somewhere hot, go check whether the options are being rebuilt under you. It's a two-line diff and one of the best effort-to-payoff ratios I've found in a while.
Have you been burned by this one, or found it hiding in a library you depend on? I'd love to hear where it bit you.
— still benchmarking the boring stuff so you don't have to
Top comments (0)