Implementing Custom ASP.NET Core Middleware for Request Logging
Learn to build custom ASP.NET Core middleware for request logging with a complete worked example, covering pipeline order, scoped service pitfalls, and async requirements.
17 Mar 2026, 23:11 UTC

The Problem and the Takeaway
Cross-cutting concerns—such as request logging, authentication, or response modification—should not be repeated inside every controller action. ASP.NET Core middleware allows you to inject logic that executes before and after downstream handlers. The critical takeaway is that middleware operates as a bidirectional pipeline: the order of registration determines the execution sequence, and each component must explicitly decide whether to pass the request forward or short-circuit the process.
How Middleware Fits Into the Pipeline
When an HTTP request arrives, ASP.NET Core processes it through a chain of delegates. Each piece of middleware receives an HttpContext (the current request/response state) and a RequestDelegate (the pointer to the next component). By calling await next(context), the middleware passes control downstream. Once the final endpoint finishes, the response flows back up the chain in reverse order, allowing the middleware to perform post-processing tasks like calculating elapsed time.
Worked Example: Request Logging Middleware
To implement a logger, create a class with a constructor that accepts the RequestDelegate and an InvokeAsync method to handle the request logic.
using Microsoft.AspNetCore.Http;
using System.Diagnostics;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
public class RequestLoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RequestLoggingMiddleware> _logger;
public RequestLoggingMiddleware(RequestDelegate next, ILogger<RequestLoggingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var stopwatch = Stopwatch.StartNew();
_logger.LogInformation("Request started: {Method} {Path}",
context.Request.Method, context.Request.Path);
try
{
// Pass control to the next middleware in the pipeline
await _next(context);
}
finally
{
stopwatch.Stop();
_logger.LogInformation("Request completed: {Method} {Path} => {StatusCode} in {ElapsedMs}ms",
context.Request.Method,
context.Request.Path,
context.Response.StatusCode,
stopwatch.ElapsedMilliseconds);
}
}
}
Register the middleware in Program.cs. It must be placed before UseRouting and UseEndpoints to ensure it captures all requests, including those that result in 404 Not Found errors:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// Register custom middleware early in the pipeline
app.UseMiddleware<RequestLoggingMiddleware>();
app.UseRouting();
app.MapControllers();
app.Run();
Verification: Run the application and send a request via curl http://localhost:5000/api/values. The console will display the "Request started" log, followed by the endpoint's internal logic, and finally the "Request completed" log with the execution time.
Execution Order and Short-Circuiting
Middleware order is strict. If you register RequestLoggingMiddleware after UseEndpoints, it will never execute for matched routes because the endpoint middleware typically terminates the pipeline.
Short-circuiting occurs when a middleware omits the await _next(context) call. This is useful for security components (e.g., an API key validator that returns 401 Unauthorized immediately), but if done accidentally, it results in a blank response and prevents the target controller from ever being reached.
Scoped Services and Lifetime Pitfalls
Middleware is instantiated as a singleton at application startup. If you inject a scoped service (like an Entity Framework DbContext) into the constructor, that service is captured for the entire life of the application, leading to ObjectDisposedException on subsequent requests.
To use scoped services, use method injection by adding the service as a parameter to the InvokeAsync method:
public async Task InvokeAsync(HttpContext context, IMyScopedService scopedService)
{
// scopedService is resolved per-request here
await _next(context);
}
Blocking I/O and Throughput Risks
InvokeAsync runs on the thread pool. Using synchronous blocking calls (such as .Result, .Wait(), or synchronous file I/O) can lead to thread pool starvation. This degrades throughput significantly under high concurrency. Always use the Async versions of .NET APIs within the middleware pipeline.
Common Mistakes Checklist
- Missing
await _next(context): Causes the request to hang or return an empty response without reaching the endpoint. - Incorrect Registration Order: Placing middleware after
UseEndpointsprevents it from running on routed requests. - Constructor Injection of Scoped Services: Leads to
ObjectDisposedException; use method injection inInvokeAsyncinstead. - Synchronous Blocking: Using
.Resultor.Wait()insideInvokeAsyncblocks the thread pool.
Limitations
- Route Data Access: Middleware registered before
UseRoutingcannot access route parameters (e.g.,{id}) because the routing decision hasn't happened yet. - Response Body Modification: Modifying the response body requires replacing
context.Response.Bodywith aMemoryStream, which increases memory overhead. - Exception Handling: Middleware intended to catch all errors (like
UseExceptionHandler) must be registered at the very beginning of the pipeline.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.