Static files, uploads and streaming responses

Serving assets with the right cache headers, accepting uploads without buffering them into memory, and streaming large responses.

Static files with sensible caching

var app = builder.Build();

// Fingerprinted assets are safe to cache forever
app.UseStaticFiles(new StaticFileOptions
{
    OnPrepareResponse = ctx =>
    {
        var headers = ctx.Context.Response.Headers;
        var path = ctx.File.Name;
        var isHashed = path.Contains('-') && path.Any(char.IsDigit);
        headers.CacheControl = isHashed
            ? "public, max-age=31536000, immutable"
            : "public, max-age=300";
    },
    ServeUnknownFileTypes = false,
});

app.UseStaticFiles(new StaticFileOptions
{
    FileProvider = new PhysicalFileProvider(
        Path.Combine(builder.Environment.ContentRootPath, "uploads")),
    RequestPath = "/media",
    // Do not let a browser render an uploaded file as HTML on your origin
    OnPrepareResponse = ctx =>
    {
        ctx.Context.Response.Headers["Content-Security-Policy"] = "sandbox";
        ctx.Context.Response.Headers["X-Content-Type-Options"] = "nosniff";
    }
});
  • asp-append-version="true" in a Razor view adds a content hash to the URL, which makes long caching safe.
  • Files served from a user upload directory must not be treated as trusted content: set Content-Disposition: attachment or a sandbox CSP for anything that is not an image.
  • Directory browsing is off by default and should stay off unless you have a specific reason and an authorisation check.
  • Static files are served before routing, so an authorisation policy on an endpoint does not protect a file in wwwroot.

Uploads and streaming

// A multipart upload handled without loading the whole body into memory
app.MapPost("/media", async (HttpRequest request, IStorage storage,
                             CancellationToken ct) =>
{
    if (!request.HasFormContentType)
        return Results.Problem(statusCode: 415, title: "multipart/form-data required");

    var form = await request.ReadFormAsync(ct);
    var file = form.Files.GetFile("file");
    if (file is null) return Results.Problem(statusCode: 400, title: "file is required");

    const long maxBytes = 25 * 1024 * 1024;
    if (file.Length > maxBytes)
        return Results.Problem(statusCode: 413, title: "file too large");

    // file.OpenReadStream() is a streaming view; do not call file.CopyToAsync
    // into a MemoryStream, which defeats the point of the length check.
    var key = await storage.PutAsync(file.OpenReadStream(), file.ContentType, ct);
    return Results.Created("/media/" + key, new { key, size = file.Length });
}).DisableAntiforgery();

// The same limit at the server level, so a huge body is rejected early
builder.Services.Configure<KestrelServerOptions>(o =>
    o.Limits.MaxRequestBodySize = 32 * 1024 * 1024);

// Streaming a large response without materialising it
app.MapGet("/export", (HttpResponse response, IReportSource source) =>
{
    response.ContentType = "text/csv";
    response.Headers.ContentDisposition = "attachment; filename=export.csv";
    return source.WriteCsvAsync(response.BodyWriter);
});
⚠️
The size limit must be enforced while reading. Checking file.Length after the framework has already buffered the body protects nothing — a client that ignores the advertised limit has already consumed your memory and disk by then.

FAQ

Why does a large upload fail before my code runs?
Because Kestrel, IIS or a reverse proxy rejected it at its own limit. Check the proxy limit as well as MaxRequestBodySize; a 413 from the edge never reaches your handler.
Should uploaded files go on disk or in object storage?
Object storage for anything that grows, and always for multiple instances. Local disk is fine for a single instance with a shared volume and a clear retention policy.

CORS, JSON options and API conventions Controllers, dependency injection and middleware

Last refreshed 2026-09-18.