Response Compression and Payload Size

Decide where responses get compressed, configure Brotli and gzip in ASP.NET Core safely, and shrink payloads with pagination and projection first.

Syntaxbuilder.Services.AddResponseCompression(o => { o.EnableForHttps = true; o.Providers.Add<BrotliCompressionProvider>(); o.Providers.Add<GzipCompressionProvider>(); }); app.UseResponseCompression();

JSON compresses well, often 5 to 10 times, so compressing large responses cuts bandwidth and time on slow client links. It costs CPU on every response, and it does nothing for the work done before the bytes leave: querying, materialising and serialising 5,000 rows is just as slow when the result is then gzipped. Shrink the payload first, then compress what remains.

Where compression should happen

If a reverse proxy, ingress controller, CDN or API gateway sits in front of the API, let it compress. It does the work once for every service behind it, it can cache compressed variants, and the API's CPU stays for application work. Compress in the application when Kestrel faces clients directly, or when the proxy cannot (some managed load balancers pass bytes through unchanged). Do not compress twice: the middleware skips responses that already carry Content-Encoding, and a proxy normally does too. Inside a cluster, service-to-service calls are usually cheaper uncompressed.

The middleware

AddResponseCompression plus UseResponseCompression, placed before anything that writes responses you want compressed. Behaviour to know:

  • With no providers configured, Brotli and gzip are both registered. When you add providers yourself, the order you add them is the preference when the client accepts several with equal quality.
  • Both providers default to CompressionLevel.Fastest. On a dynamic API that is usually right; Optimal or SmallestSize costs much more CPU per response. At Fastest Brotli is not guaranteed to beat gzip, as the example's output shows for that payload: measure your own responses.
  • Only MIME types in ResponseCompressionDefaults.MimeTypes are compressed; application/json is included, application/problem+json and custom types are not unless you add them.
  • The middleware adds Vary: Accept-Encoding so caches keep compressed and plain variants apart.
  • EnableForHttps is false by default.

HTTPS and BREACH

Compressing an encrypted response that contains both a secret (a CSRF token, an API key) and text the attacker can influence (a reflected query value) lets an attacker who can observe response sizes guess the secret byte by byte. That is why HTTPS compression is opt-in. For a JSON API whose responses do not mix secrets with reflected input, enabling it is a normal decision; exclude responses that do, per request, with IHttpsCompressionFeature.

Payload size: the bigger lever

TechniqueSaves
Pagination with a capped limit (keyset for large tables)Database time, memory and bytes; bounds the worst case
Projection into a DTO with only the fields the screen needsColumns read, serialisation time, accidental data exposure
Omitting nulls, no indented JSON in production10-30% of bytes on sparse objects
Streaming large exportsMemory: no full buffer, no large object heap

The failure mode to avoid is an unbounded list endpoint: fine with 200 orders in test, a 40 MB response and an out-of-memory pod once a customer has 300,000.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Cut a 2 MB product catalogue response to under 200 KB on mobile networks by enabling Brotli at the ingress
  • Replace an unbounded GET /customers/{id}/orders with keyset pages of at most 100 projected rows
  • Enable HTTPS compression for a JSON API while excluding the checkout endpoint that reflects input next to a token

More examples

Compressing at the ingress instead

With the ingress compressing, the API leaves <code>UseResponseCompression</code> out and spends its CPU on requests. Note the explicit <code>application/problem+json</code> so error bodies are covered too, and a minimum length so tiny responses are not compressed, where headers and CPU cost more than the bytes saved. These keys are ingress-nginx's, and that controller is retired, so on a maintained proxy (Envoy Gateway, Traefik's compress middleware, a cloud load balancer or CDN) set the same three things: algorithms, MIME types and a minimum size.

Example · yaml
# ingress-nginx controller ConfigMap: compression for every service behind the ingress.
# ingress-nginx is retired (best-effort maintenance ended March 2026, no more security
# fixes); new clusters should use a maintained Gateway API implementation or the cloud
# load balancer or CDN, which expose the same settings.
apiVersion: v1
kind: ConfigMap
metadata:
  name: ingress-nginx-controller
  namespace: ingress-nginx
data:
  use-gzip: "true"
  gzip-level: "5"
  gzip-min-length: "1024"
  gzip-types: "application/json application/problem+json text/plain text/csv"
  enable-brotli: "true"
  brotli-level: "4"
  brotli-types: "application/json application/problem+json text/plain text/csv"

HTTPS compression with a per-response opt-out

<code>EnableForHttps</code> turns compression on for TLS responses, and the MIME list is extended so ProblemDetails and CSV exports are compressed too. The checkout endpoint reflects a query value next to a token, the exact shape BREACH exploits, so it sets <code>HttpsCompressionMode.DoNotCompress</code> on its own response.

Example · csharp
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.ResponseCompression;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAntiforgery();
builder.Services.AddResponseCompression(options =>
{
    options.EnableForHttps = true;
    options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(["application/problem+json", "text/csv"]);
});

var app = builder.Build();
app.UseResponseCompression();

app.MapGet("/orders/{id:int}", (int id) =>
    TypedResults.Ok(new { id, customerId = "CUST-0042", total = 129.50m }));

// Echoes the caller's input next to a per-session secret: never compress over HTTPS.
app.MapGet("/checkout/session", (HttpContext context, IAntiforgery antiforgery, string returnUrl) =>
{
    context.Features.Get<IHttpsCompressionFeature>()!.Mode = HttpsCompressionMode.DoNotCompress;
    var tokens = antiforgery.GetAndStoreTokens(context);
    // returnUrl is untrusted: validate it as a local URL before any client redirects to it.
    return TypedResults.Ok(new { returnUrl, antiforgeryToken = tokens.RequestToken });
});

app.Run();

Projection and keyset pagination with EF Core

The <code>Select</code> into <code>OrderSummary</code> makes EF Core read four columns instead of the address JSON and internal notes, which also stops those leaking to clients. The composite <code>(CustomerId, Id)</code> index is what makes keyset pagination cost the same on page 1 and page 5,000: without it the database scans by <code>Id</code> and filters, or reads the customer's rows and sorts. The clamp means no caller can ask for everything, and taking the customer from the authenticated principal stops one customer listing another's orders.

Example · csharp
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;

public sealed class Order
{
    public int Id { get; set; }
    public string CustomerId { get; set; } = "";
    public DateTimeOffset PlacedAt { get; set; }
    public decimal Total { get; set; }
    public string Status { get; set; } = "";
    public string ShippingAddressJson { get; set; } = "";
    public string InternalNotes { get; set; } = "";
}

public sealed record OrderSummary(int Id, DateTimeOffset PlacedAt, decimal Total, string Status);
public sealed record OrderPage(IReadOnlyList<OrderSummary> Items, int? NextAfterId);

public sealed class OrdersDb(DbContextOptions<OrdersDb> options) : DbContext(options)
{
    public DbSet<Order> Orders => Set<Order>();

    // The keyset query filters on CustomerId and seeks on Id: this index serves both.
    protected override void OnModelCreating(ModelBuilder modelBuilder) =>
        modelBuilder.Entity<Order>().HasIndex(o => new { o.CustomerId, o.Id });
}

public static class OrderListEndpoint
{
    private const int MaxPageSize = 100;

    // Map with .RequireAuthorization(); the customer comes from the token, never the query
    // string, so a caller cannot page through another customer's orders.
    public static async Task<IResult> ListAsync(
        OrdersDb db, ClaimsPrincipal user, int? afterId, int? limit, CancellationToken ct)
    {
        var customerId = user.FindFirstValue("customer_id");
        if (customerId is null) return TypedResults.Forbid();

        var size = Math.Clamp(limit ?? 25, 1, MaxPageSize);
        var items = await db.Orders
            .AsNoTracking()
            .Where(o => o.CustomerId == customerId && o.Id > (afterId ?? 0))
            .OrderBy(o => o.Id)
            .Take(size)
            .Select(o => new OrderSummary(o.Id, o.PlacedAt, o.Total, o.Status))
            .ToListAsync(ct);
        return TypedResults.Ok(new OrderPage(items, items.Count == size ? items[^1].Id : null));
    }
}

Discussion

  • Be the first to comment on this lesson.