Skip to content
Kumar Chandrachooda
.NET

Every Way to Send a Response

The Send facade is a struct, its methods return a fake Void, and every sender secretly marks the response started before writing a byte. A tour of the response surface the deep dive skipped - files, ranges, redirects, interceptors and all. Part 2 of FastEndpoints — The Missing Chapters.

By Kumar Chandrachooda 31 May 2026 7 min read
One endpoint, many exits - the Send facade fans out to every response shape

The original series spent a whole part on how a request gets into an endpoint and another on how a DTO gets bound. The way responses get out only ever appeared in fragments: auto-send in Part 3, server-sent events in Part 16. That leaves most of the response surface - files with range support, redirects with an open-redirect guard, CreatedAt link generation, response interceptors - undocumented. This part is the missing tour.

The facade is a struct on purpose

Every handler in this series has ended with something like await Send.OkAsync(response). That Send property is lazily created on the endpoint:

protected ResponseSender<TRequest, TResponse> Send => _sender ??= new(this);

ResponseSender<TRequest, TResponse> is a readonly struct wrapping the endpoint, and the source says why, in a comment I appreciate for its empiricism: "being a struct here reduces gc pressure (verified with benchmarks)" (from the FastEndpoints source, Endpoint.Send.cs). A facade object per request would be one more allocation on the hottest path in the library; a struct rides the stack.

The second trick is the return type. Every sender returns Task<Void> - not Task, but a task of a sentinel Void type with a single Instance. That exists so HandleAsync overloads and expression-bodied handlers can return Send.OkAsync(...) uniformly, and so the generic machinery always has some result type to thread through.

The struct implements a small public interface, IResponseSender, which exposes exactly three things: HttpContext, the shared ValidationFailures list, and the EndpointDefinition. That interface is the extension seam - write an extension method on IResponseSender and it appears on Send in every endpoint:

public static class SenderExtensions
{
    public static Task<Void> TeapotAsync(this IResponseSender s, CancellationToken ct = default)
        => s.HttpContext.Response.SendStringAsync("short and stout", 418, cancellation: ct).ToVoidTask();
}

The full menu

Reading Endpoint.Send.cs end to end, the senders group naturally:

Bodies. OkAsync(response) and ResponseAsync(response, statusCode) are the JSON workhorses; StringAsync(content, statusCode, contentType) writes text; EmptyJsonObject() sends a literal {} - handy for clients that choke on an empty body but ignore unknown objects.

Bare statuses. NoContentAsync (204), NotFoundAsync (404), UnauthorizedAsync (401), ForbiddenAsync (403), NotModifiedAsync (304), and the general StatusCodeAsync(int). One small source-reading smile: the XML doc on ForbiddenAsync describes it as “403 unauthorized” - the method does the right thing even where the comment fumbles the vocabulary.

Locations. CreatedAtAsync<TEndpoint>(routeValues, body) and AcceptedAtAsync<TEndpoint>(...) produce 201/202 with a Location header pointing at another endpoint class - no route string in sight, the same philosophy as the route-less tests. Under the hood the target endpoint's name comes from the configurable NameGenerator, and the URL from ASP.NET Core's LinkGenerator. Two caveats the docs are honest about and I'll repeat: if you gave the target endpoint a custom name via .WithName(...), the generic overload can no longer find it - use the string-name overloads CreatedAtAsync("MyName", ...) instead. And the implementation resolves LinkGenerator through the static service resolver before falling back to RequestServices, with an in-source warning not to “fix” it - that ordering is what lets unit tests fabricate link generation without a server. More on that resolver in Part 5.

Bytes, files, streams. BytesAsync(byte[]), FileAsync(FileInfo), and StreamAsync(stream, fileName, fileLengthBytes, contentType, ...) all funnel into one helper, and all take an enableRangeProcessing flag. More below, because the range implementation deserves its own section.

Redirects. RedirectAsync(location, isPermanent, allowRemoteRedirects) is a tiny method with a security posture: unless you explicitly pass allowRemoteRedirects: true, it delegates to Results.LocalRedirect, which throws for absolute URLs to other hosts. The default protects you from the classic open-redirect bug where ?returnUrl=https://evil.example sails through. You have to ask for the dangerous behavior.

Escape hatches. ResultAsync(IResult) executes any Minimal API TypedResults value, so nothing ASP.NET Core can express is out of reach. HeadersAsync(...) sets headers and a status with no body - built for HEAD endpoints. ErrorsAsync(statusCode) sends whatever is in ValidationFailures through the configured error envelope - the same one from Part 5 of the parent series.

The overload that dodges a CancellationToken

OkAsync has four overloads, and one of them exists to defuse a trap. If your endpoint's response type is object (common on Ep-style quick endpoints), then Send.OkAsync(ct) is ambiguous in the worst way: a CancellationToken is an object, so overload resolution happily picks OkAsync(TResponse response) and your client receives a serialized cancellation token. The implementation checks for exactly this - if the response DTO type is object and the argument turns out to be a CancellationToken, it forwards to the no-body overload instead of serializing it. On .NET 9+ the library also stacks [OverloadResolutionPriority] on the intended overload. It is a small thing, but it is the difference between a library that compiles and a library that has met its users.

What every sender does before writing

The struct methods are thin; the real work is public extension methods on HttpResponse itself (SendOkAsync, SendStreamAsync, SendRedirectAsync...), which means everything Send can do is also available in a pre-processor or plain middleware. Reading those in HttpResponseExtensions.cs, a fixed ritual precedes every write:

  1. Mark the response started. MarkResponseStart() drops a flag into HttpContext.Items. The pipeline later asks ResponseStarted(), which checks Response.HasStarted or that flag - because, per a blunt comment in the endpoint code, HasStarted alone doesn't work on AWS Lambda. The keys for these Items entries are the strings "0" through "4", chosen - says another comment - to avoid boxing int keys on dictionary lookups. Frugality all the way down.
  2. Apply the global response modifier. If you configured GlobalResponseModifier (a hook covered properly in Part 4), it runs here, on every response from every endpoint - the place for a tenant header or a deprecation warning.
  3. Store the response DTO. The DTO goes into HttpContext.Items under "FastEndpointsResponse" - explicitly “for third party libs to access the response”. That unglamorous line is a load-bearing seam: it is exactly how the OData package (Part 7) fishes the IQueryable back out of the pipeline.
  4. Lift [ToHeader] properties. Response DTO properties decorated with [ToHeader] are written as response headers instead of body members - the mirror image of [FromHeader] binding.

Only then does JSON serialization happen, through the swappable ResponseSerializer hook, with the configured charset appended to the content type.

Ranges, preconditions, and one honest limitation

The file/stream senders share StreamHelper, a compact implementation of HTTP range semantics. When enableRangeProcessing is on and a lastModified is supplied, it evaluates If-Modified-Since and If-Unmodified-Since (yielding 304 or 412 before a byte moves), advertises Accept-Ranges: bytes, and parses the Range header. A satisfiable single range becomes a 206 Partial Content with a correct Content-Range; an unsatisfiable one becomes 416 with Content-Range: bytes */length. Multi-part ranges - Range: bytes=0-99,200-299 - are deliberately not supported; the helper just sends the full body. For resumable downloads and video scrubbing, a single range is all real clients send, so the trade is sound; if you need multipart ranges you need different machinery. The copy itself runs through a 64 KB buffered StreamCopyOperation, and a client disconnect aborts the connection rather than throwing.

public override async Task HandleAsync(GetReportRequest req, CancellationToken ct)
{
    var file = new FileInfo(Path.Combine(_reportsRoot, $"{req.Id}.pdf"));
    if (!file.Exists) { await Send.NotFoundAsync(ct); return; }
    await Send.FileAsync(file, "application/pdf",
                         lastModified: file.LastWriteTimeUtc,
                         enableRangeProcessing: true, cancellation: ct);
}

Interceptors, and where auto-send fits

Send.InterceptedAsync(response, statusCode) routes the response through an IResponseInterceptor registered on the endpoint with ResponseInterceptor(...) in Configure(). The interceptor receives the response object, the intended status code, the HttpContext, and the validation failures, and may write the response itself; the normal JSON send happens afterward only if the response hasn't started. Call InterceptedAsync without configuring one and you get an immediate InvalidOperationException - “Response interceptor has not been configured!” - rather than a silent no-op. It is the response-side sibling of the pre-processors from Part 6 of the parent series, useful when the transformation belongs to this endpoint rather than the whole pipeline.

And if your handler sends nothing at all? The pipeline's auto-send takes over: a null response DTO becomes a 204, anything else is serialized as 200 - unless the endpoint declared DontAutoSendResponse(), which is precisely the lever the OData integration pulls.

The lesson I take from this corner of the source: the showy part of a web framework is routing and binding, but the part users touch on every single request is the response surface - and here it is a struct, a sentinel type, five string keys, and a pile of small guards that each prevent one specific bad afternoon. Next part: a middleware that would rather crash your app at boot than let you misconfigure it - the antiforgery check.