Swap System.Text.Json for REDox in a Minimal API
/ 9 min read
Updated:Found this post helpful?
Buy me a coffeeTable of Contents
A new serializer with headline numbers like “2.8x faster than System.Text.Json” is catnip for anyone who runs web APIs. REDox is exactly that: a high-performance, token-based structured data engine from Capcom, built as part of the tech behind their next-generation game engine and shipped as an Apache-2.0 NuGet package.
Note the emphasis on “game engine”, we’ll get back to that.
So like any curious developer, I wired REDox into an ASP.NET Core Minimal API in place of System.Text.Json, pointed
a benchmark at it, and ran a straight System.Text.Json vs REDox comparison to see whether the serializer made a
difference.
This post is in two parts:
first
the mechanics of swapping the serializer, then the honest verdict on whether it is an improvement. If you’d rather
squeeze more out of System.Text.Json itself, I’ve also covered speeding up JSON APIs with source generators.
One note before we start: REDox targets .NET 10, so you’ll need that SDK to follow along.
What Is REDox?
REDox is a high-performance, token-based structured data engine for .NET, open-sourced by Capcom, the studio behind Resident Evil and Street Fighter 6. It’s built as a core component of their next-generation game engine, so a future entry in one of those franchises may well run on it.
It’s not just a JSON serializer. REDox parses structured data into a compact, fixed-size token DOM, then uses that structural index for reading, editing, format conversion, and both sequential and automatic parallel deserialization. The same token model backs JSON, JSON5, CBOR, MessagePack, INI, and more.
For this post I only care about the plain JSON serialize/deserialize path, because that’s what a web API leans on. The
API surface deliberately looks a lot like System.Text.Json:
using REDox.Json;
var json = JsonSerializer.Serialize(player);var restored = JsonSerializer.Deserialize<Player>(json);Install the core package:
dotnet add package CAPCOM.REDoxThe NuGet package ID is CAPCOM.REDox; the namespace is REDox.Json.
Ugh! Minimal APIs Are Wired to System.Text.Json
Sadly, I started out with the mindset, “just swap the serializer”, but reality is harsh. Minimal
API’s JSON helpers (TypedResults.Ok, Results.Json, and automatic return-value serialization) are hard-wired to System.Text.Json through JsonOptions and JsonTypeInfoResolver. There is no configuration knob that tells TypedResults.Ok to use a different serializer.
REDox ships a CAPCOM.REDox.Serialization.SystemTextJson compatibility package, and the name is a little misleading for this use case. It adapts JsonSerializerOptions values into REDox settings; it does not plug REDox into the ASP.NET Core output pipeline. So it won’t help you here.
To support this library, the extension point is the IResult interface. You own the content type, status code,
and then write to the response body, so you can serialize however you like. (If you haven’t customized Minimal API
responses before, endpoint filters are the other hook worth knowing.)
How to Swap the Serializer in a Minimal API with IResult
Here’s how I integrated the library into an ASP.NET Core app. REDox exposes several serialize overloads, including
SerializeToUtf8Bytes, Serialize(Stream, ...), and Serialize(IBufferWriter<byte>, ...). Since Response.BodyWriter
is an IBufferWriter<byte>, the result type serializes straight into the response pipe, with no intermediate string
and no separate UTF-8 copy:
public sealed class RedoxJsonResult<T> : IResult{ private readonly T _value; private readonly int _statusCode;
public RedoxJsonResult(T value, int statusCode = StatusCodes.Status200OK) { _value = value; _statusCode = statusCode; }
public async Task ExecuteAsync(HttpContext httpContext) { httpContext.Response.StatusCode = _statusCode; httpContext.Response.ContentType = "application/json; charset=utf-8";
// Response.BodyWriter is a PipeWriter, which implements IBufferWriter<byte>. REDox.Json.JsonSerializer.Serialize(httpContext.Response.BodyWriter, _value); await httpContext.Response.BodyWriter.FlushAsync(); }}To make it feel native, I hang it off Results.Extensions, which is the officially sanctioned spot for custom results:
public static class RedoxResultExtensions{ public static IResult Redox<T>( this IResultExtensions _, T value, int statusCode = StatusCodes.Status200OK) => new RedoxJsonResult<T>(value, statusCode);}Now the two endpoints read almost identically. One goes through the stock System.Text.Json pipeline, the other through REDox:
var catalog = ProductFactory.CreateCatalog(500);
app.MapGet("/stj", () => TypedResults.Ok(catalog));app.MapGet("/redox", () => Results.Extensions.Redox(catalog));It’s as simple as thirty lines, no magic. There’s one rough edge worth calling out before we continue.
Friction: No camelCase by Default
ASP.NET Core’s default JsonOptions applies camelCase to System.Text.Json output, so /stj returns "id" and
"sku". REDox doesn’t read that configuration. It’s a completely separate serializer. So /redox returns "Id" and
"Sku". The payloads were otherwise byte-for-byte equivalent (same 500 objects, same values, same 468 KB), but if your
clients expect camelCase, that divergence can become a real headache to handle yourself. System.Text.Json, by
contrast, is deeply configurable once you lean into it, with custom converters for cases like polymorphic
types or serializing interface instances.
Part Two: Is It Actually Faster?
Now the question that started all this. I built a realistic payload, a List<Product> of 500 entries, where each
Product is a C# record with nested records, string arrays, a Dictionary<string,string>, decimals, nullable fields, and DateTimeOffset values. It looks like an e-commerce catalog response, about 468 KB of JSON.
The raw serializer microbenchmark with BenchmarkDotNet and [MemoryDiagnoser], on .NET 10, Apple M4 Pro. Serialize uses
the UTF-8 byte path (SerializeToUtf8Bytes), which is what a web API actually writes:
| Method | Mean | Allocated |
|---|---|---|
| Serialize (STJ) | 419.9 us | 458.69 KB |
| Serialize (REDox) | 357.6 us | 500.77 KB |
| Deserialize (STJ) | 1,009.4 us | 1375.97 KB |
| Deserialize (REDox) | 872.9 us | 1142.56 KB |
Two things stand out:
- Serialize: REDox is ~15% faster than STJ (357.6us vs 419.9us) and allocates ~9% more (500.77 KB vs 458.69 KB).
- Deserialize: REDox is ~13% faster and allocates ~17% less. This is the clearest win.
They’re well below the 1.6x to 2.8x figures in REDox’s
README. Those headline numbers come from canada.json, citm_catalog.json, and twitter.json, datasets with
different shapes, more numeric-heavy and less record-nesting than a typical C# API response. Payload shape dictates
your result. This is the entire reason you benchmark your own workload instead of trusting anyone’s summary table, including mine.
And at the HTTP Layer?
What I actually wanted to know is whether a faster serializer makes a faster endpoint. So I hit both /stj and /redox on the running app and measured.
This is a rough measurement: a sequential curl loop, not a real load tool like k6, wrk, or bombardier. Each request’s latency is curl’s time_total (the HTTP request itself), while requests/sec is the wall-clock over a sequential shell loop, so it also carries loop and process-spawn overhead. There’s no concurrency at all. Treat these as a rough, same-machine comparison between the two endpoints, not a measure of Kestrel’s ceiling.
/stj: 129.76 req/s, mean=1.47ms, p50=1.38ms, p99=3.13ms/redox: 128.44 req/s, mean=1.80ms, p50=1.89ms, p99=3.52msThe two endpoints are a dead heat, ~1% apart, with /redox a hair slower at every percentile in this run. I
can’t cleanly attribute the small gap: this harness doesn’t isolate serialization from everything else in the request,
and the difference is well inside measurement noise. On this payload, swapping to REDox produced no endpoint win, and
the ~15% serialize advantage from the microbenchmark didn’t show up at the socket.
This run doesn’t explain why they tied. Allocation is easier to measure cleanly, and it’s where I expected REDox to be worse, so I looked there next.
How Much Memory Does Each Serializer Allocate?
Throughput is noisy; allocation is not. So I added a /mem diagnostic that measures the managed bytes each serializer
allocates to produce one ~468 KB UTF-8 body, using SerializeToUtf8Bytes for both. These are the serializer methods,
not the live endpoints. The real /stj path uses the framework’s Utf8JsonWriter pipeline and /redox uses
BodyWriter, both of which write into pooled buffers and should allocate the same or less than a fresh byte[].
SerializeToUtf8Bytes is a clean, repeatable stand-in. I ran the API through Rider’s run configuration at 1,000
iterations:
| Per ~468 KB body | STJ (SerializeToUtf8Bytes) |
REDox (SerializeToUtf8Bytes) |
|---|---|---|
| Allocated bytes | ~468,800 | ~513,100 |
| Allocated ÷ output size | ~1.0x | ~1.1x |
| GC collections (gen0/1/2) | 9 / 9 / 9 | 8 / 8 / 8 |
REDox’s byte API allocates about 9% more than System.Text.Json’s to produce the same body (513 KB vs 469 KB), and
triggers slightly fewer garbage collections. REDox’s serializer allocation sits right next to STJ’s.
This measures the serializer’s allocation footprint, not endpoint latency, and not the exact BodyWriter path the
live /redox endpoint uses. REDox’s serializer allocates close to System.Text.Json, which matches the two endpoints
landing in a dead heat.
What I Actually Learned
For a payload this size, I came in expecting serialization to be a small slice of total request cost. Kestrel overhead, the response write, and framework plumbing all compete with it, and nothing in these numbers contradicted that. The deserialize win is the more convincing result, so if your hot path is parsing large request bodies (ingestion endpoints, webhooks, bulk imports), REDox is worth a closer look.
But the headline I came in chasing, “plug in the fast serializer, get a faster API,” didn’t hold up for this workload. REDox’s UTF-8 serialize is ~15% faster, but that win didn’t reach the endpoint. I didn’t isolate why, though my read is that serialization is a small slice of a 468 KB request next to everything else Kestrel does.
None of this is a knock on REDox. It’s a young, genuinely interesting project, and its architecture goes well beyond “faster JSON”: a mutable token DOM, lazy decoding, automatic parallel deserialization, and one converter model across many formats. The token DOM and format-conversion story are the parts I’d actually reach for, more than shaving microseconds off an endpoint.
And that’s the real lesson for me. It’s easy to get wrapped up chasing faster, better numbers, but a library is optimized for a very specific use case. REDox is built for a game engine: structured data on disk, running inside a single application lifetime, with bulk parallel loads. Its sweet spot is that kind of work, not a single 468 KB HTTP response, where the serializer swap made no measurable difference either way. It would have been nice to see a measurable win for web development, but I’m not mad that it didn’t. I just wanted to answer the curious thought in the back of my head: what if it’s better?
So before you swap serializers for throughput, remember what the library was built for, and measure the whole request with your real payloads and a proper load tool before you trust any number, mine included.