blog post image
Andrew Lock avatar

Andrew Lock

~7 min read

Short-circuit routing in .NET 8

Exploring the .NET 8 preview - Part 11

In this post I describe a small new feature added to ASP.NET Core in .NET 8—short-circuit routing. I discuss how this differs from normal routing, why you might want to use it, and how it's implemented by the framework.

Understanding endpoint routing in ASP.NET Core

There are a few fundamental concepts in ASP.NET Core, such as

  • Configuration
  • Dependency injection
  • Middleware
  • Routing

Each of these concepts have easier and harder aspects, but I find the interaction between routing and middleware is often the one people struggle with the most. Specifically, the fact that routing in ASP.NET Core is handled by two pieces of middleware:

  • EndpointRoutingMiddleware—This middleware chooses which registered endpoint to execute for a given request at runtime. It's sometimes referred to as RoutingMiddleware (which is the name I use in this post).
  • EndpointMiddleware—This middleware is typically placed at the end of the middleware pipeline. The middleware executes the endpoint selected by the RoutingMiddleware for a given request.

You might be wondering why there's two pieces of middleware instead of just one. Separating the selection of an endpoint from the execution of an endpoint gives a specific advantage—you can execute middleware between these two events. This middleware can change its behaviour based on metadata associated with the endpoint that will be executed, before the endpoint executes.

There are several features that rely on this behaviour to work correctly. The most common examples are the AuthorizationMiddleware and CorsMiddleware, which must be placed between the RoutingMiddleware and EndpointMiddleware so that they know which policies to apply for the selected endpoint.

Image of routing in ASP.NET Core

This is generally how routing works whether you're using minimal APIs or MVC controllers, and it's highly extensible. You can add additional metadata to endpoints, and then add additional middleware between the RoutingMiddleware and EndpointMiddleware to enable new behaviour.

Sometimes, however, an endpoint doesn't need authorization or CORS support, or the potential extensibility. That's where short-circuit routing comes in.

What is short-circuit routing?

Short-circuit routing is a new feature in .NET 8. It's applied to one or more endpoints in your application, and it means that the endpoint conceptually "skips" the middleware between the RoutingMiddleware and the EndpointMiddleware.

You can think of the feature "skipping" the middleware between the RoutingMiddleware and EndpointMiddleware, or you can think of the RoutingMiddleware immediately executing the endpoint and short-circuiting the remainder of the pipeline. Implementation wise, ASP.NET Core does the latter.

Short-circuit routing in ASP.NET Core

You can mark an endpoint to use short-circuit routing by calling ShortCircuit() on the endpoint in minimal APIs. For example:

app.MapGet("/", () => "Hello World!")
   .ShortCircuit(); // 👈 Add this

This adds the short-circuit metadata to the endpoint, and means that this "Hello World!" endpoint will execute in the RoutingMiddleware instead of in the EndpointMiddleware.

You can also optionally provide a status code, which will be automatically set on the response:

app.MapGet("/", () => "Hello World!")
   .ShortCircuit(201); // 👈 Sets the status code to 201

When is short-circuit routing useful?

So you might be wondering when this would be useful? The endpoint-routing design is like that for a reason, because it's very useful. If you use short-circuit routing, you can't apply CORS or authorization to the endpoint (or other features that work in similar ways).

I think the main use case is to reduce the overhead of requests that you know will either return a 404 response, or which you know won't ever need authorization or CORS. Some examples might be well-known URLs that browsers request automatically, or other standard paths. For example:

  • /robots.txt—tells web-scrapers like Google what to index.
  • /favicon.ico—The tab icon in a browser, which is automatically requested by the browser.
  • /.well-known/* (all paths prefixed with .well-known/)—Used by various specifications like OpenID Connect, security.txt, or webfinger.

These are simple, well-known URLs that the browser and other sites may request automatically. If you don't include them in your site, then every request for these is going to pass all the way through your middleware and eventually return a 404. That's a lot of extra work that's just not necessary. And with every browser requesting these files, that could add up. Short-circuit routes let you avoid that overhead.

Let's take the examples above, and put them into an actual app:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

         // 👇 return a 404 for favicon.ico
app.MapGet("/favicon.ico", () => Task.CompletedTask).ShortCircuit(404);
app.MapGet("/robots.txt", // 👈 return a valid robots.txt
    () => """
          User-agent: *
          Allow: /
          """).ShortCircuit(200);

// 👇 any request starting with /.well-known/ returns a 404
app.MapShortCircuit(404, ".well-known"); 

app.UseRouting(); // Not required (as added by default) but being explicit in this example

// Any request NOT short-circuited will execute this "middleware", 
// which always throws an exception.
app.Use((HttpContext _, RequestDelegate _)
    => throw new Exception("You shall not pass!"));

// This "normal" endpoint won't ever be executed
app.MapGet("/", () => "Can't ever get to this");
app.Run();

If you run this application, you get the expected results:

  • A request to /favicon.ico returns a 404 response.
  • A request to /robots.txt returns a 200 response with a valid robots.txt body.
  • Any request that starts with .well-known returns a 404 response.

All other requests proceed through the middleware pipeline after the RoutingMiddleware, hit the custom exception middleware, and the pipeline terminates:

The exception thrown when the middleware executes

Obviously you wouldn't do this in a real app, I'm just trying to prove the fact the short-circuiting works! 😅

That's pretty much all there is to the short-circuit feature, so in the final part of this post we have a look at how the feature is implemented behind the scenes.

Taking a peak at the implementation

There are essentially only three parts to the implementation:

  • The ShortCircuit() extension method that adds ShortCircuitMetadata() to an endpoint.
  • The MapShortCircuit() extension method that adds a short-circuit route-prefix endpoint.
  • The changes to the RoutingMiddleware that check for the metadata and execute the endpoint if required

We'll start with RouteShortCircuitEndpointConventionBuilderExtensions, the class that provides the ShortCircuit() method. I've reproduced it below (with some comments etc removed for brevity).

using Microsoft.AspNetCore.Routing.ShortCircuit;

namespace Microsoft.AspNetCore.Builder;

public static class RouteShortCircuitEndpointConventionBuilderExtensions
{
    // 👇 These fields cache some common status code values
    private static readonly ShortCircuitMetadata _200ShortCircuitMetadata = new(200);
    private static readonly ShortCircuitMetadata _401ShortCircuitMetadata = new(401);
    private static readonly ShortCircuitMetadata _404ShortCircuitMetadata = new(404);
    private static readonly ShortCircuitMetadata _nullShortCircuitMetadata = new(null);

    public static IEndpointConventionBuilder ShortCircuit(
        this IEndpointConventionBuilder builder, int? statusCode = null)
    {
        var metadata = statusCode switch
        {
            200 => _200ShortCircuitMetadata,
            401 => _401ShortCircuitMetadata,
            404 => _404ShortCircuitMetadata,
            null => _nullShortCircuitMetadata,
            _ => new ShortCircuitMetadata(statusCode)
        };

        // 👇 Add the ShortCircuitMetadata instance to the endpoint 
        builder.Add(b => b.Metadata.Add(metadata));
        return builder;
    }
}

Pretty simple! Next we'll look at the MapShortCircuit() extension method on RouteShortCircuitEndpointRouteBuilderExtensions. Again, I've simplified and removed some comments

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
namespace Microsoft.AspNetCore.Routing;

public static class RouteShortCircuitEndpointRouteBuilderExtensions
{
    // This is the no-op RequestDelegate executed for the endpoints
    private static readonly RequestDelegate _shortCircuitDelegate = (context) => Task.CompletedTask;

    // You can pass multiple route-prefixes to be handled and
    // each one is converted to an endpoint
    public static IEndpointConventionBuilder MapShortCircuit(
        this IEndpointRouteBuilder builder, int statusCode, params string[] routePrefixes)
    {
        // wrap all the endpoints in a group
        var group = builder.MapGroup("");
        foreach (var routePrefix in routePrefixes)
        {
            string route = routePrefix.EndsWith("/", StringComparison.OrdinalIgnoreCase)
                ? $"{routePrefix}{{**catchall}}"
                : $"{routePrefix}/{{**catchall}}";
                               //👆 normalise the route to end with /{**catchall}

            // Map each of the route prefixes with the no-op handler
            group.Map(route, _shortCircuitDelegate)
                .ShortCircuit(statusCode) // Mark the request as short-circuited
                .Add(endpoint =>
                {
                    // update the name of the endpoint
                    endpoint.DisplayName = $"ShortCircuit {endpoint.DisplayName}";
                    // make sure the route is last (as it's a catch-all route)
                    ((RouteEndpointBuilder)endpoint).Order = int.MaxValue;
                });
        }

        // Return the group as an IEndpointConventionBuilder so you
        // can add additional metadata if you wish
        return new EndpointConventionBuilder(group);
    }
}

So the MapShortCircuit() method creates a catch-all route endpoint for each route-prefix, adds the ShortCircuitMetadata, and updates the metadata of the endpoint.

Which finally brings us to the changes in the RoutingMiddleware, where the short-circuiting happens. After the middleware has selected an endpoint, it checks to see if the endpoint has ShortCircuitMetadata, and if it does, it executes the endpoint and returns:

var shortCircuitMetadata = endpoint.Metadata.GetMetadata<ShortCircuitMetadata>();
if (shortCircuitMetadata is not null)
{
    return ExecuteShortCircuit(shortCircuitMetadata, endpoint, httpContext);
}

The short-circuit endpoint execution is shown below - I haven't tried to tidy this up much at all, so a high level summary is:

  • Check that the endpoint is valid for being short-circuited
  • Set the status code to the value in the ShortCircuitMetadata
  • Execute the RequestDelgate for the endpoint
  • Log the details if required
private Task ExecuteShortCircuit(ShortCircuitMetadata shortCircuitMetadata,
    Endpoint endpoint, HttpContext httpContext)
{
    // This check mirrors the implementation in EndpointMiddleware
    if (!_routeOptions.SuppressCheckForUnhandledSecurityMetadata)
    {
        // If you try to short circuit an endpoint with 
        // authorization, CORS, or antiforgery metadata
        // it throws an exception
        if (endpoint.Metadata.GetMetadata<IAuthorizeData>() is not null)
        {
            ThrowCannotShortCircuitAnAuthRouteException(endpoint);
        }

        if (endpoint.Metadata.GetMetadata<ICorsMetadata>() is not null)
        {
            ThrowCannotShortCircuitACorsRouteException(endpoint);
        }

        if (endpoint.Metadata.GetMetadata<IAntiforgeryMetadata>() is { RequiresValidation: true } &&
            httpContext.Request.Method is {} method &&
            HttpExtensions.IsValidHttpMethodForForm(method))
        {
            ThrowCannotShortCircuitAnAntiforgeryRouteException(endpoint);
        }
    }

    // Set the status code that was recorded when the endpoint was mapped
    if (shortCircuitMetadata.StatusCode.HasValue)
    {
        httpContext.Response.StatusCode = shortCircuitMetadata.StatusCode.Value;
    }

    // Execute the endpoint (optionally with logging)
    if (endpoint.RequestDelegate is not null)
    {
        if (!_logger.IsEnabled(LogLevel.Information))
        {
            // Avoid the AwaitRequestTask state machine allocation if logging is disabled.
            return endpoint.RequestDelegate(httpContext);
        }

        Log.ExecutingEndpoint(_logger, endpoint);

        try
        {
            var requestTask = endpoint.RequestDelegate(httpContext);
            if (!requestTask.IsCompletedSuccessfully)
            {
                return AwaitRequestTask(endpoint, requestTask, _logger);
            }
        }
        catch
        {
            Log.ExecutedEndpoint(_logger, endpoint);
            throw;
        }

        Log.ExecutedEndpoint(_logger, endpoint);

        return Task.CompletedTask;

        static async Task AwaitRequestTask(Endpoint endpoint, Task requestTask, ILogger logger)
        {
            try
            {
                await requestTask;
            }
            finally
            {
                Log.ExecutedEndpoint(logger, endpoint);
            }
        }

    }
    else
    {
        Log.ShortCircuitedEndpoint(_logger, endpoint);
    }
    return Task.CompletedTask;
}

And that's it! That's the whole implementation of the short-circuit feature. It's not a particularly ground breaking one, but it should reduce the overhead for common no-op endpoints, which is a nice little bonus.

Summary

In this post I described the new short-circuit routing feature in .NET 8. A short-circuit endpoint executes immediately in the RoutingMiddleware, instead of in the EndpointMiddleware. This is useful for avoiding the overhead of running authorization or CORS middleware for endpoints that don't need those features, or which are simply going to return a 404 anyway. At the end of the post I showed how the feature was implemented using endpoint metadata.

Andrew Lock | .Net Escapades
Want an email when
there's new posts?