← Back to Blog

How We Reduced Our API Response Payload by 40% by Formatting and Auditing Our JSON Output

Our product listing endpoint was slow and heavy. Formatting the raw response revealed we were sending navigation properties, shadow properties, and circular reference chains that no client ever needed — 340KB where 12KB was sufficient.

Pankaj Kumar
Senior Software Engineer — .NET, Blazor, ASP.NET Core
4+ years building production .NET and Blazor applications. Every DevToolsHub tool and article comes from real daily development work — not documentation summaries.
Published 24 Jul 2026· Last reviewed Jul 2026· 10 min read · About the author →
I've seen over-serialized EF Core responses in every production codebase I've inherited. The pattern is always the same: someone returned an entity directly from a controller, it worked, and nobody measured what was actually being sent.
Key takeaways
  • Returning EF Core entities directly from controllers serializes navigation properties, shadow properties, and sometimes the entire object graph
  • Circular references in EF Core cause either infinite loops or Reference handling overhead in System.Text.Json
  • DTOs (Data Transfer Objects) are the correct solution — serialize only what the client needs
  • A formatted JSON audit of your API responses is the fastest way to find over-serialization problems
  • System.Text.Json's JsonIgnore attribute and custom converters are tools of last resort — DTOs are better
Table of Contents

The problem

Our product catalogue API endpoint was serving a mobile app with a list of products for a category page. The mobile team reported that the category page took 3-4 seconds to load on a slow connection, and they asked us to look at the API response size. We used our network tab in Chrome to check — the GET /api/categories/5/products response was 340KB for 20 products.

That's 17KB per product record. These are simple products: name, SKU, price, a short description, and two image URLs. No files. No embedded media. The entire data for one product should be under 500 bytes, putting 20 products well under 10KB. Something was seriously wrong with what we were serializing.

What the controller looked like

// The original controller — returning EF Core entities directly
[HttpGet("categories/{id}/products")]
public async Task<IActionResult> GetProducts(int id)
{
    var products = await _context.Products
        .Include(p => p.Category)
        .Include(p => p.Images)
        .Include(p => p.Supplier)
        .Include(p => p.Tags)
        .Where(p => p.CategoryId == id && p.IsActive)
        .ToListAsync();

    return Ok(products); // directly returning EF Core entity objects
}

Returning EF Core entities directly is the original sin of .NET web API development. The entities include navigation properties pointing to other entities, those entities have their own navigation properties, and the result is a serialized object graph that looks nothing like what the client needs.

What formatting the response revealed

We pasted one product record from the response into the JSON Formatter. The formatted output was 147 lines long for a single product. The structure included:

  • The Category navigation property (fully serialized, including the Products collection back-reference — a circular reference chain)
  • The Supplier entity (address, contact person, bank details, all serialized)
  • The Tags collection (each tag with its own Products back-reference)
  • EF Core shadow properties: _ts, __jdraft, and other internal tracking fields
  • The Images collection with full image metadata including internal storage paths

The System.Text.Json serializer was configured with ReferenceHandler.Preserve (added to handle circular references), which added "$id" and "$ref" properties to every object in the response for JSON reference tracking. These alone added about 15% overhead on top of the already bloated structure.

The fix: project to DTOs

// Step 1: Define a DTO that contains only what the mobile client needs
public record ProductSummaryDto(
    int Id,
    string Name,
    string Sku,
    decimal Price,
    string Description,
    string ThumbnailUrl,
    IReadOnlyList<string> Tags
);

// Step 2: Project directly in the LINQ query — no entity materialisation
[HttpGet("categories/{id}/products")]
public async Task<IActionResult> GetProducts(int id)
{
    var products = await _context.Products
        .Where(p => p.CategoryId == id && p.IsActive)
        .Select(p => new ProductSummaryDto(
            p.Id,
            p.Name,
            p.Sku,
            p.Price,
            p.Description,
            p.Images
                .Where(img => img.IsThumbnail)
                .Select(img => img.Url)
                .FirstOrDefault() ?? string.Empty,
            p.Tags.Select(t => t.Name).ToList()
        ))
        .ToListAsync();

    return Ok(products);
}

The Select projection runs in the database query — EF Core translates the LINQ expression to SQL and fetches only the columns we need. The response size for 20 products dropped from 340KB to 9KB. The response time dropped from 3.2 seconds (average on a slow mobile connection) to 0.4 seconds.

Removing ReferenceHandler.Preserve

One of the worst offenders was ReferenceHandler.Preserve, which we'd added globally to avoid circular reference exceptions when serializing entity graphs. With DTOs, circular references don't exist — a ProductSummaryDto has no navigation properties pointing back to entities. We could remove the global setting:

// Before: global ReferenceHandler.Preserve added overhead to every response
builder.Services.AddControllers().AddJsonOptions(opts =>
{
    opts.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.Preserve;
    // This adds $id and $ref to every object — increases payload size significantly
});

// After: remove ReferenceHandler.Preserve once you've eliminated entity returns
builder.Services.AddControllers().AddJsonOptions(opts =>
{
    opts.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
    opts.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
    // No ReferenceHandler — clean output
});

Finding over-serialization in your existing API

The fastest audit method: take a production response from your most-used API endpoint and format it. Count how many properties appear that your client doesn't use. Every property the client doesn't use is payload you're paying to serialize, transmit, and parse.

// For one-off auditing during development, log the serialized response size
// Add this middleware to your ASP.NET Core pipeline for development only:
if (app.Environment.IsDevelopment())
{
    app.Use(async (ctx, next) =>
    {
        var original = ctx.Response.Body;
        using var ms = new MemoryStream();
        ctx.Response.Body = ms;

        await next();

        var size = ms.Length;
        if (size > 50_000) // warn if response exceeds 50KB
        {
            var path = ctx.Request.Path;
            Console.WriteLine(
                $"[PAYLOAD WARNING] {path} returned {size:N0} bytes");
        }

        ms.Position = 0;
        await ms.CopyToAsync(original);
        ctx.Response.Body = original;
    });
}

When [JsonIgnore] is the right tool

There are legitimate cases where you can't create a separate DTO — for example, a very simple domain model used in an internal tool where the overhead of a DTO layer isn't justified. In those cases, [JsonIgnore] on specific properties is acceptable:

// [JsonIgnore] as a last resort when DTO overhead is genuinely not justified
public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }

    // Suppress navigation properties to prevent circular references and over-serialization
    [JsonIgnore]
    public Category? Category { get; set; }

    [JsonIgnore]
    public Supplier? Supplier { get; set; }

    // Expose only what the client needs from the Tags collection
    [JsonIgnore]
    public ICollection<Tag> Tags { get; set; } = new List<Tag>();

    // Computed property that the serializer CAN see
    [NotMapped]
    public IReadOnlyList<string> TagNames =>
        Tags.Select(t => t.Name).ToList();
}

But I want to be clear: this is second-best. Every [JsonIgnore] attribute is an implicit contract between your entity model and your API output. When someone modifies the entity model, they must remember to update the [JsonIgnore] attributes. This coupling breaks down as the codebase grows. DTOs separate concerns properly — the entity model is for data access, the DTO is for serialization.

The generalizable lesson

Every ASP.NET Core API that I've inherited or audited has at least one endpoint that returns EF Core entities directly. It's the path of least resistance when you're building quickly. It works. And then one day you format the response and discover you're sending your entire object graph to a mobile client that needed three fields.

The rule is simple: never return an EF Core entity from a controller. Always project to a DTO in the LINQ query. The projection happens in SQL, which means fewer columns fetched from the database, fewer bytes deserialized, fewer bytes serialized into JSON, and fewer bytes transmitted over the network. Every step is faster. And the JSON Formatter is your fastest way to audit what you're actually sending before your users feel it.

Try the free tool
JSON Formatter

Format and inspect JSON responses instantly. Paste a raw API response to make its structure visible — the fastest way to audit what your serializer is actually sending.

Open JSON Formatter