I had a little serialize helper that sailed through code review. It looked like this: return JsonSerializer.Serialize(order, new JsonSerializerOptions { ... }). A fresh options object, built right there in the method, on every call. Every post I'd ever half-read told me this was a cardinal sin, the kind that turns a fast endpoint slow and earns you a 100x regression. So I braced for the worst, wrote a benchmark, and went hunting for the catastrophe.
I didn't find it. On the path my code actually took, I got 1.9x. That was surprising enough that I kept pulling the thread, and the real answer turned out to be more useful than the scary one.
The three ways to get options wrong (and right)
Here's the payload. A small order, the kind of thing a handler serializes thousands of times a second.
record Order(Guid Id, string Customer, DateTime PlacedAtUtc, LineItem[] Items, string? Note); record LineItem(string Name, int Quantity, decimal UnitPrice); And here are the three ways I serialized it. Case A is the fix everyone recommends: build the options once, reuse the instance.
// A — one shared instance, reused everywhere. var shared = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; string SerializeShared(Order o) => JsonSerializer.Serialize(o, shared); Case B is my sin: a new instance every call, but with the exact same configuration each time. This is what the accidental version almost always looks like in real code.
// B — new instance each call, identical config. static string SerializeNewSameConfig(Order o) { var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; return JsonSerializer.Serialize(o, options); } Case C is the one I didn't write but easily could have: a new instance each call where the configuration also changes. Here I just nudge MaxDepth to make every instance distinct, but in the wild this is what you get when options are assembled from per-request data.
// C — new instance each call, config varies. static string SerializeNewVaryingConfig(Order o, int i) { var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, MaxDepth = 32 + (i & 63) // harmless, but makes every instance distinct }; return JsonSerializer.Serialize(o, options); } The measurement is boring on purpose: warm up a thousand times so the JIT and first-use caches aren't in the number, collect garbage, then time 200,000 serializations and take the best of five rounds. I also grab GC.GetAllocatedBytesForCurrentThread() around the loop so I can see the allocations, not just the clock.
static (double ms, long bytes) Measure(Func<Order, string> fn, Order o, int iterations) { for (int i = 0; i < 1000; i++) _ = fn(o); GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); long before = GC.GetAllocatedBytesForCurrentThread(); var sw = Stopwatch.StartNew(); for (int i = 0; i < iterations; i++) _ = fn(o); sw.Stop(); return (sw.Elapsed.TotalMilliseconds, GC.GetAllocatedBytesForCurrentThread() - before); } What came out
This isn't a lab. It's a 2-core container on .NET 10, best-of-five, and I care about the ratios, not the absolute milliseconds.
Iterations per run: 200,000 A shared instance : 153.7 ms 186.2 MB B new, same config : 295.6 ms 224.7 MB C new, varying config : 593.9 ms 329.5 MB B vs A : 1.9x time 1.2x alloc C vs A : 3.9x time 1.8x alloc So my supposedly-criminal Case B costs about 1.9x the time and 1.2x the memory. Real, worth fixing, but nowhere near the horror story.
Why B isn't the disaster the internet promised
The reason is something JsonSerializerOptions does behind your back. The expensive part of serialization isn't the writing. It's building the metadata that describes how to walk your type (JsonTypeInfo for Order, LineItem, and every property). Years ago, each options instance built and cached that metadata on itself, so a new instance per call meant rebuilding the whole thing every single time. That's where the 100x stories came from.
Modern .NET keeps a cache keyed on the options configuration, not just the instance. When I create a new options object with the same settings as one it's already seen, the runtime recognizes the configuration and reuses the metadata it built before. I still pay to allocate the object and do the equivalence lookup every call, which is the 1.9x, but I don't pay to rebuild the metadata, the part that used to hurt.
Case C is where that safety net has a hole. Every instance there has a different MaxDepth, so the configuration never matches and the cache can't help. Close to 4x and climbing. The lesson isn't that new is fine now. The penalty scales with how different your options are, and a per-call object is one small refactor away from becoming the per-request-varying kind.
What I actually do
I still make it static readonly and reuse it. Not because 1.9x scares me. On a background job that serializes once an hour I genuinely would not care, and you shouldn't either. I do it because a new JsonSerializerOptions sitting inside a hot method is a smell. It's the thing that looks innocent until someone adds options.Converters.Add(somethingFromTheRequest) six months later and tips you into Case C. Reusing one instance closes that door before anyone can walk through it.
If you want to confirm the shape on your own hardware, here's the full runnable program, a net10.0 console project. Drop it in Program.cs and dotnet run -c Release. (My usual repo push got blocked by a sandbox permission hiccup this run, so you get the whole thing right here instead of a link.)
using System.Diagnostics; using System.Text.Json; using System.Text.Json.Serialization; var order = new Order( Guid.Parse("8a1f6c2e-0b3d-4e5a-9c7f-112233445566"), "Acme Robotics", new DateTime(2026, 11, 6, 7, 10, 0, DateTimeKind.Utc), [ new("Servo motor", 4, 23.50m), new("Control board", 1, 189.00m), new("Cable harness", 2, 7.25m) ], "Leave at the loading dock"); const int Iterations = 200_000; var shared = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; string A(Order o, int i) => JsonSerializer.Serialize(o, shared); static string B(Order o, int i) => JsonSerializer.Serialize(o, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }); static string C(Order o, int i) => JsonSerializer.Serialize(o, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, MaxDepth = 32 + (i & 63) }); static (double ms, long bytes) Measure(Func<Order, int, string> fn, Order o, int n) { for (int i = 0; i < 1000; i++) _ = fn(o, i); GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); long before = GC.GetAllocatedBytesForCurrentThread(); var sw = Stopwatch.StartNew(); for (int i = 0; i < n; i++) _ = fn(o, i); sw.Stop(); return (sw.Elapsed.TotalMilliseconds, GC.GetAllocatedBytesForCurrentThread() - before); } static (double ms, long bytes) BestOf(Func<Order, int, string> fn, Order o, int n, int rounds) { double best = double.MaxValue; long bytes = 0; for (int r = 0; r < rounds; r++) { var (ms, b) = Measure(fn, o, n); if (ms < best) { best = ms; bytes = b; } } return (best, bytes); } var a = BestOf(A, order, Iterations, 5); var b = BestOf(B, order, Iterations, 5); var c = BestOf(C, order, Iterations, 5); Console.WriteLine($"Iterations per run: {Iterations:N0}\n"); Console.WriteLine($"A shared instance : {a.ms,9:N1} ms {a.bytes / 1048576.0,8:N1} MB"); Console.WriteLine($"B new, same config : {b.ms,9:N1} ms {b.bytes / 1048576.0,8:N1} MB"); Console.WriteLine($"C new, varying config : {c.ms,9:N1} ms {c.bytes / 1048576.0,8:N1} MB\n"); Console.WriteLine($"B vs A : {b.ms / a.ms,5:N1}x time {(double)b.bytes / a.bytes,4:N1}x alloc"); Console.WriteLine($"C vs A : {c.ms / a.ms,5:N1}x time {(double)c.bytes / a.bytes,4:N1}x alloc"); record Order(Guid Id, string Customer, DateTime PlacedAtUtc, LineItem[] Items, string? Note); record LineItem(string Name, int Quantity, decimal UnitPrice); Have you ever gone looking for a legendary performance bug and found it had quietly been fixed out from under you? I'd love to hear which one.
— still benchmarking things nobody asked me to measure
