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
Categorynavigation property (fully serialized, including theProductscollection back-reference — a circular reference chain) - The
Supplierentity (address, contact person, bank details, all serialized) - The
Tagscollection (each tag with its ownProductsback-reference) - EF Core shadow properties:
_ts,__jdraft, and other internal tracking fields - The
Imagescollection 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.