.NET  

Benchmarking JSON Union Serialization in .NET 11

API contracts frequently represent a value that can have several distinct shapes.

An order operation might produce:

OrderCreated
OrderRejected
PaymentRequired

A traditional C# implementation can model these outcomes with inheritance, interfaces, discriminated DTOs, or a custom result wrapper.

C# 15 introduces native union types, allowing developers to express a closed set of possible cases directly in the type system. .NET 11 also adds System.Text.Json support for serializing and deserializing C# union types.

That combination creates an interesting question for API developers:

How does JSON serialization of C# union types behave compared with established polymorphic API designs?

The answer should not be based only on syntax.

For an HTTP API, the relevant measurements include:

  • Serialization time

  • Deserialization time

  • Allocations

  • Garbage-collection pressure

  • JSON payload size

  • Contract clarity

  • Round-trip correctness

  • Behavior as the number of union cases increases

This article presents a benchmark strategy for evaluating those characteristics without assuming that one serialization approach is universally faster.

What Are C# Union Types?

A union defines a closed set of possible types.

For example:

public record class OrderCreated(int OrderId);

public record class OrderRejected(string Reason);

public record class PaymentRequired(decimal Amount);

public union OrderResult(
    OrderCreated,
    OrderRejected,
    PaymentRequired);

The compiler knows that an OrderResult must contain one of the declared cases.

That makes exhaustive pattern matching possible:

static string Describe(OrderResult result)
{
    return result switch
    {
        OrderCreated created =>
            $"Order {created.OrderId} created.",

        OrderRejected rejected =>
            $"Order rejected: {rejected.Reason}",

        PaymentRequired payment =>
            $"Payment required: {payment.Amount}"
    };
}

Microsoft documents union types as a C# 15 feature currently available through the .NET 11 preview SDK.

The important point for this article is that the union exists at the C# type-system level, while JSON remains the public wire representation.

Those two contracts must be evaluated separately.

Why Serialization Matters

A union may be elegant inside a .NET application but still produce an unsuitable API contract.

Consider:

C# Domain
    |
    v
OrderResult
    |
    v
System.Text.Json
    |
    v
JSON
    |
    v
HTTP Client

The client does not receive a C# union.

It receives JSON.

Therefore, an API evaluation should answer:

  1. What JSON is produced?

  2. Can the JSON be deserialized correctly?

  3. How is the active case identified?

  4. Does the contract remain understandable to non-.NET clients?

  5. What happens when a new case is added?

  6. What is the serialization cost?

These questions are more important than simply comparing two C# declarations.

Create a Benchmark Project

Create a dedicated benchmark project:

dotnet new console -n JsonUnionBenchmarks
cd JsonUnionBenchmarks

dotnet add package BenchmarkDotNet

Because C# 15 union types are part of the .NET 11 preview ecosystem, the benchmark should use the exact .NET 11 SDK and compiler version being evaluated.

A simplified project file can look like:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net11.0</TargetFramework>
    <LangVersion>preview</LangVersion>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>

The exact target framework and preview SDK should be recorded with the benchmark results because preview implementations can change.

Microsoft currently documents C# 15 as the latest C# preview and identifies .NET 11 preview SDKs as the environment for trying the feature.

Define the Union

Start with a small API contract:

public record class OrderCreated(
    int OrderId,
    string CustomerId);

public record class OrderRejected(
    string Reason);

public record class PaymentRequired(
    int OrderId,
    decimal Amount);

public union OrderResult(
    OrderCreated,
    OrderRejected,
    PaymentRequired);

Now create one instance of each case:

private readonly OrderResult _created =
    new OrderCreated(1001, "C-100");

private readonly OrderResult _rejected =
    new OrderRejected("Credit check failed.");

private readonly OrderResult _payment =
    new PaymentRequired(1001, 249.99m);

These become the benchmark inputs.

Establish a Traditional Polymorphic Baseline

A benchmark needs a meaningful comparison.

Use a conventional abstract hierarchy:

public abstract record LegacyOrderResult;

public sealed record LegacyOrderCreated(
    int OrderId,
    string CustomerId)
    : LegacyOrderResult;

public sealed record LegacyOrderRejected(
    string Reason)
    : LegacyOrderResult;

public sealed record LegacyPaymentRequired(
    int OrderId,
    decimal Amount)
    : LegacyOrderResult;

Now the same three outcomes can be represented without C# unions.

This gives us two domain models:

Union
OrderResult
   ├── OrderCreated
   ├── OrderRejected
   └── PaymentRequired

Traditional
LegacyOrderResult
   ├── LegacyOrderCreated
   ├── LegacyOrderRejected
   └── LegacyPaymentRequired

The benchmark can now compare the serialization behavior of both approaches.

Benchmark Serialization

Start with the simplest benchmark:

[MemoryDiagnoser]
public class SerializationBenchmarks
{
    private readonly OrderResult _union =
        new OrderCreated(1001, "C-100");

    private readonly LegacyOrderResult _legacy =
        new LegacyOrderCreated(1001, "C-100");

    [Benchmark]
    public string SerializeUnion()
    {
        return JsonSerializer.Serialize(_union);
    }

    [Benchmark]
    public string SerializeLegacy()
    {
        return JsonSerializer.Serialize(_legacy);
    }
}

The benchmark should be executed in Release configuration:

dotnet run -c Release

Do not publish the resulting numbers without recording the test environment.

A useful benchmark report should include:

.NET SDK
C# compiler
OS
CPU
Memory
BenchmarkDotNet version
Build configuration
Runtime configuration

Measure Allocations

Serialization performance is not only about elapsed time.

Use:

[MemoryDiagnoser]

BenchmarkDotNet can report memory-related measurements alongside execution time.

This matters because serialization creates strings and frequently involves intermediate objects or buffers.

A benchmark table should eventually contain:

MetricUnionTraditional
Mean serialization timeMeasureMeasure
Allocated bytesMeasureMeasure
Gen 0 collectionsMeasureMeasure
Output sizeMeasureMeasure

The numbers must come from the benchmark environment.

Do not substitute theoretical values.

Benchmark Each Union Case

Do not benchmark only the most common case.

Measure:

OrderCreated
OrderRejected
PaymentRequired

For example:

[Benchmark]
public string SerializeCreated()
{
    return JsonSerializer.Serialize(
        new OrderResult(
            new OrderCreated(
                1001,
                "C-100")));
}

Then create equivalent benchmarks for rejected and payment-required results.

This matters because the shape of each case can affect the amount of JSON generated and therefore the serialization workload.

Measure Payload Size

The serialized JSON size is an important API metric.

For example:

var json =
    JsonSerializer.Serialize(_union);

var byteCount =
    Encoding.UTF8.GetByteCount(json);

Measure payload sizes for each case.

A comparison table might look like:

CaseUnion JSONTraditional JSON
CreatedMeasureMeasure
RejectedMeasureMeasure
Payment requiredMeasureMeasure

Payload size matters particularly for remote APIs where network transfer is part of the request cost.

A small difference in serializer CPU time may matter less than a substantially different wire payload.

Benchmark Deserialization

Serialization is only half of an API contract.

Now benchmark deserialization.

private readonly string _createdJson =
    """
    {
        "..."
    }
    """;

[Benchmark]
public OrderResult? DeserializeUnion()
{
    return JsonSerializer.Deserialize<OrderResult>(
        _createdJson);
}

The exact JSON should come from the serializer rather than being invented manually during the benchmark setup.

A robust test flow is:

C# Object
   ↓
Serialize
   ↓
JSON
   ↓
Deserialize
   ↓
Union

Then verify that the semantic case remains the same.

Test Round-Trip Correctness

A benchmark should not measure speed while silently producing an incorrect object.

Add a correctness test:

var original =
    new OrderCreated(
        1001,
        "C-100");

OrderResult union = original;

var json =
    JsonSerializer.Serialize(union);

var restored =
    JsonSerializer.Deserialize<OrderResult>(json);

Then verify that:

Original case = OrderCreated
Restored case = OrderCreated

and that important values remain unchanged.

For example:

var created =
    restored switch
    {
        OrderCreated value => value,
        _ => throw new InvalidOperationException(
            "Unexpected union case.")
    };

Assert.Equal(1001, created.OrderId);

A serializer benchmark without round-trip correctness is incomplete.

Understand the Wire Contract

One of the most important design questions is how the active union case is represented in JSON.

A client might need to distinguish:

OrderCreated
OrderRejected
PaymentRequired

from the JSON document.

The API contract therefore needs a reliable discriminator or another unambiguous representation.

Do not assume that a C# union declaration automatically gives you the exact public API contract you want.

.NET 11 adds union support to System.Text.Json, including customization APIs for union contract discovery and case naming.

Before exposing the result publicly, inspect the actual JSON.

For example:

Console.WriteLine(
    JsonSerializer.Serialize<OrderResult>(
        new OrderCreated(1001, "C-100")));

Record the actual output produced by the tested runtime.

That output—not a conceptual example—should be treated as the benchmark's wire-format evidence.

Test Custom Contract Configuration

Public APIs often require specific JSON naming conventions.

For example, your application may use:

camelCase
snake_case
custom discriminators
versioned contracts

A production benchmark should therefore test the configured JsonSerializerOptions.

For example:

var options =
    new JsonSerializerOptions
    {
        PropertyNamingPolicy =
            JsonNamingPolicy.CamelCase
    };

var json =
    JsonSerializer.Serialize(
        _union,
        options);

Measure default and application-configured serialization separately.

A serializer that performs well under default settings may behave differently once converters, naming policies, or other options are introduced.

Compare With a Discriminator-Based DTO

Another useful baseline is a conventional DTO designed specifically for JSON.

For example:

public sealed record OrderResponse
{
    public required string Type { get; init; }

    public int? OrderId { get; init; }

    public string? CustomerId { get; init; }

    public string? Reason { get; init; }

    public decimal? Amount { get; init; }
}

An order-created response might become:

{
  "type": "created",
  "orderId": 1001,
  "customerId": "C-100"
}

A rejected response:

{
  "type": "rejected",
  "reason": "Credit check failed."
}

This design is not as strongly modeled at the C# type-system level, but it can be very explicit as a wire contract.

Now the benchmark has three approaches:

C# Union
Traditional polymorphism
Explicit API discriminator DTO

That is a much more useful comparison for API architects.

Benchmark a Generic Result Wrapper

Another common pattern is:

public sealed record ApiResult<T>(
    bool Success,
    T? Data,
    string? Error);

For example:

ApiResult<Order> response =
    new(
        Success: true,
        Data: order,
        Error: null);

This design is simple but permits invalid combinations.

For example:

Success = true
Data = null
Error = null

or:

Success = false
Data = order
Error = null

A union can model mutually exclusive outcomes more directly.

Nevertheless, the wrapper remains a valuable benchmark baseline because it is common in real .NET APIs.

Benchmark Case Count

Union serialization should also be tested as the number of cases grows.

Create variants such as:

3 cases
5 cases
10 cases
20 cases

The purpose is not to claim that larger unions are inherently slower.

It is to measure whether the serializer's behavior changes materially as the contract becomes more complex.

For example:

Benchmark A
3 union cases

Benchmark B
10 union cases

Benchmark C
20 union cases

Record:

  • Serialization time

  • Deserialization time

  • Allocations

  • JSON size

  • Round-trip correctness

This gives the article a stronger information-gain angle than a single three-case example.

Test Large Payloads

Small DTOs can hide serialization behavior.

Create a realistic case:

public record class OrderCreated(
    int OrderId,
    string CustomerId,
    string[] Items,
    decimal Amount,
    DateTimeOffset CreatedAt);

Then benchmark:

Small payload
Medium payload
Large payload

For example:

Small → 1 item
Medium → 25 items
Large → 500 items

The exact values should be chosen to reflect the target application.

The objective is to determine whether the union representation remains relevant when most of the serialization cost comes from the payload itself.

Benchmark With Source Generation

For production APIs, also consider System.Text.Json source generation.

Create a JSON context:

[JsonSerializable(typeof(OrderResult))]
internal partial class ApiJsonContext
    : JsonSerializerContext
{
}

Then benchmark serialization using the generated metadata where supported by the union configuration being tested.

Conceptually:

var json =
    JsonSerializer.Serialize(
        result,
        ApiJsonContext.Default.OrderResult);

The benchmark matrix becomes:

ConfigurationSerializationDeserializationAllocations
Reflection-basedMeasureMeasureMeasure
Source-generatedMeasureMeasureMeasure

This is important because a production application may already use source generation for performance and trimming considerations.

The benchmark should compare configurations actually intended for deployment.

Test HTTP-Level Performance

A serializer microbenchmark does not represent an entire API request.

Create a minimal ASP.NET Core endpoint:

app.MapGet(
    "/orders/{id:int}",
    (int id) =>
    {
        OrderResult result =
            new OrderCreated(
                id,
                "C-100");

        return Results.Ok(result);
    });

Then benchmark through HTTP.

The complete path becomes:

HTTP Request
     ↓
Routing
     ↓
Application Logic
     ↓
Union Construction
     ↓
JSON Serialization
     ↓
HTTP Response

Measure:

  • Requests per second

  • p50 latency

  • p95 latency

  • p99 latency

  • Allocations/request

  • Response size

This is where you determine whether any serializer-level difference is meaningful to the application.

Do Not Confuse Microbenchmark and API Results

Suppose one implementation serializes slightly faster in a microbenchmark.

That does not automatically mean the HTTP API will be faster.

The complete request may also include:

Network
TLS
Routing
Authentication
Database
Business logic
Logging
Serialization

If serialization accounts for only a small fraction of total request time, its optimization may have little observable effect.

The reverse can also be true for high-throughput internal APIs where serialization dominates the request.

Measure before drawing conclusions.

Test Invalid JSON

Deserialization benchmarks should include failure scenarios.

Test:

Missing discriminator
Unknown case
Missing required property
Incorrect property type
Malformed JSON
Null value
Unexpected additional data

For example:

Assert.Throws<JsonException>(() =>
    JsonSerializer.Deserialize<OrderResult>(
        invalidJson));

The exact exception behavior should be verified against the runtime version under test.

Do not assume that all invalid payloads produce the same failure mode.

Test Unknown Future Cases

This is particularly important for API versioning.

Imagine the server adds:

FraudReview

while an older client understands only:

Created
Rejected
PaymentRequired

The API needs a deliberate compatibility strategy.

Questions to answer:

  • Can the old client reject the response safely?

  • Is there a fallback representation?

  • Does deserialization fail closed?

  • Can the API version the contract?

  • Is the discriminator extensible?

A closed C# union provides strong compile-time guarantees inside the application, but external clients may not have the same type-system information.

Therefore, API versioning remains necessary.

Benchmark API Evolution

Add a fourth case:

public record class FraudReview(
    string Reason);

Then update:

public union OrderResult(
    OrderCreated,
    OrderRejected,
    PaymentRequired,
    FraudReview);

Re-run:

Serialization
Deserialization
Payload size
Round-trip
Existing client tests

This gives you a practical way to evaluate whether adding union cases creates compatibility problems.

Recommended Benchmark Matrix

A comprehensive benchmark can use:

DimensionTest
Domain representationUnion vs hierarchy vs DTO
Case count3 / 5 / 10 / 20
Case typeReference types
Payload sizeSmall / medium / large
SerializationYes
DeserializationYes
Round-tripYes
AllocationsYes
Payload sizeYes
Source generationYes
HTTP endpointYes
Invalid JSONYes
New union caseYes
API compatibilityYes

This gives the benchmark enough depth to support an architectural decision.

Common Mistakes

Measuring Only Serialization Time

Serialization is only one part of API performance.

Include allocations, payload size, deserialization, and HTTP-level behavior where appropriate.

Assuming Union Means Smaller JSON

The CLR type declaration does not automatically determine whether the resulting JSON is smaller than another contract.

Measure the actual payload.

Ignoring Deserialization

Many services spend significant time processing incoming JSON.

Benchmark both directions.

Using Only Tiny Objects

A three-property object can produce misleading conclusions.

Test payload sizes representative of the real API.

Comparing Different JSON Contracts

If one implementation produces a fundamentally different wire format, the benchmark is not an apples-to-apples serializer comparison.

First define equivalent semantic contracts.

Publishing Preview Results as Permanent Facts

C# 15 and .NET 11 union support are preview technology. Results from a preview runtime should identify the exact version used.

Troubleshooting

The Union Does Not Deserialize

First inspect the exact JSON produced by the serializer.

Then verify:

  • Union type declaration

  • Runtime version

  • JSON options

  • Available union cases

  • Generated metadata or converters

Do not assume that a JSON shape copied from an example corresponds to your exact runtime configuration.

Payloads Differ Between Implementations

Compare semantic content rather than raw string output.

JSON property ordering or formatting can differ without changing the API meaning.

For payload-size benchmarks, use UTF-8 byte counts.

Source Generation Fails

Verify that the exact union type and its cases are supported by the runtime/compiler version being evaluated and that the generated context includes the required types.

Preview features can change between SDK versions.

HTTP Performance Shows No Difference

That can be a valid result.

If serialization represents only a small portion of request processing, improving serializer behavior may not materially change end-to-end latency.

Use profiling to identify the actual bottleneck.

Best Practices

  1. Benchmark both serialization and deserialization.

  2. Record allocations alongside execution time.

  3. Measure actual UTF-8 payload size.

  4. Test every union case.

  5. Include invalid JSON scenarios.

  6. Compare against a realistic existing API design.

  7. Benchmark source-generated serialization when applicable.

  8. Test representative payload sizes.

  9. Include HTTP-level measurements for production decisions.

  10. Record the exact .NET SDK and runtime.

  11. Treat preview benchmark results as version-specific.

  12. Validate wire compatibility before publishing a union-based API.

  13. Test what happens when new union cases are introduced.

  14. Keep domain-type design separate from external API compatibility requirements.

Frequently Asked Questions

Does .NET 11 support JSON serialization for C# union types?

Yes. .NET 11 introduces System.Text.Json support for C# union types, including serialization and deserialization capabilities.

Are C# union types automatically better for APIs?

No.

They provide a stronger closed-world type model and exhaustive handling in C#, but an API still needs a clear, stable JSON contract.

Are union types faster to serialize than inheritance?

There is no universal answer.

The correct result depends on the runtime version, case structure, JSON configuration, payload size, and serializer implementation. Benchmark the exact workload.

Should I benchmark serialization or the entire HTTP API?

Ideally, both.

Microbenchmarks explain serializer behavior. HTTP-level tests determine whether that difference matters to users of the service.

Are C# 15 union types production-ready?

C# 15 is currently a preview language version, and .NET 11 preview SDKs are used to evaluate it. Microsoft documents union types as part of the C# 15 preview feature set.

For a public API with strict long-term compatibility requirements, evaluate the feature's maturity before making it a permanent contract dependency.

What is more important: serializer speed or API compatibility?

For most public APIs, compatibility should be established first.

A small serialization improvement is rarely worth an unstable or difficult-to-consume wire contract.

Conclusion

C# 15 union types provide a compelling way to express closed API and domain outcomes directly in the type system. .NET 11 extends that model into System.Text.Json, allowing union values to participate in JSON serialization and deserialization.

But the introduction of a new type-system feature does not automatically make an API faster or better.

A serious evaluation should measure:

Union representation
       ↓
Serialization
       ↓
Payload size
       ↓
Deserialization
       ↓
Allocation behavior
       ↓
HTTP performance
       ↓
Compatibility

The most useful benchmark is therefore not:

"Union serialization is X% faster."

It is:

"For this workload, on this runtime,
with this payload and this API contract,
these are the measured trade-offs."

That distinction is especially important while C# 15 and .NET 11 remain in preview.

Union types may ultimately provide their greatest value through contract correctness, exhaustive handling, and clearer domain modeling, while runtime performance remains workload-dependent.

For developers evaluating them for API contracts, the safest approach is to benchmark the feature against the existing implementation, inspect the actual JSON wire format, test round trips and compatibility, and only then decide whether the new type-system capability belongs in the architecture.