Using subdomain based pipeline branching for static files delivery optimization in ASP.NET Core

I would say that the best way for hosting static files is CDN. But sometimes there are valid reasons not to use CDN and serve static files directly from the application. In such scenario it's important to think about performance. I'm not going to write about caching and cache busting, for those subjects I suggest this post by Andrew Lock. What I want to focus on is eliminating unnecessary request bytes and processing on the server side.

Let's imagine an ASP.NET Core MVC application which uses cookie based authentication. The Startup may look more or less like below.

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        ...

        services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
            .AddCookie();

        services.AddMvc();
    }

    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        ...

        app.UseStaticFiles();

        app.UseAuthentication();

        app.UseMvc();
    }
}

After login this configuration will result in about 600 bytes of state coming from cookies (it can easily be more). Those are data which are send to the server with every request and server must parse them and store in memory. In case of publicly available static files this is waste of resources. The web development knows a generic solution for this problem - using a cookie-free origin.

Setting up a subdomain to act as a cookie-free origin doesn't require anything ASP.NET Core specific. All that is needed is subdomain configured to the same IP address as the main domain of the application, for example example.com and static.example.com (in development you can set this up by editing hosts file). In the result the cookies set by example.com will not be send to static.example.com. But just configuring the subdomain is not enough. If left like that it would expose the whole application under static.example.com. Certainly this is not desired, we want to branch the pipeline.

Subdomain based pipeline branching

ASP.NET Core provides couple of ways to branch the pipeline. The domain information is available to the application through Host header, so the way which allows for examining it is predicate based MapWhen() method. A simple extension could look like this.

public static class MapSubdomainExtensions
{
    public static IApplicationBuilder MapSubdomain(this IApplicationBuilder app,
        string subdomain, Action<IApplicationBuilder> configuration)
    {
        // Parameters validation removed for brevity.
        ...

        return app.MapWhen(GetSubdomainPredicate(subdomain), configuration);
    }

    private static Func<HttpContext, bool> GetSubdomainPredicate(string subdomain)
    {
        return (HttpContext context) =>
        {
            string hostSubdomain = context.Request.Host.Host.Split('.')[0];

            return (subdomain == hostSubdomain);
        };
    }
}

This extensions compares the provided subdomain with whatever is in the Host header before first dot. This can be considered a simplification, but an acceptable one. With the extension we can separate the pipeline for static files from the rest of application.

public class Startup
{
    ...

    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        ...

        app.MapSubdomain("static", ConfigureStaticSubdomain);

        app.UseAuthentication();

        app.UseMvc();
    }

    private static void ConfigureStaticSubdomain(IApplicationBuilder app)
    {
        app.UseStaticFiles();
    }
}

Running the application now would result in static files not loading. The reason for that is simple, the generated content URLs are incorrect. It would be nice to modify a single place to handle correct URL generation.

Prefixing content URLs

As far as I know all static files URLs (unless entered manually) are generated by calling IUrlHelper.Content() method. Luckily ASP.NET Core makes it relatively easy to replace default implementation of IUrlHelper. First thing which is needed is custom implementation. It can be derived from default UrlHelper.

public class ContentSubdomainUrlHelper : UrlHelper
{
    private readonly string _contentSubdomain;

    public ContentSubdomainUrlHelper(ActionContext actionContext, string contentSubdomain)
        : base(actionContext)
    {
        if (String.IsNullOrWhiteSpace(contentSubdomain))
        {
            throw new ArgumentNullException(nameof(contentSubdomain));
        }

        _contentSubdomain = contentSubdomain;
    }

    public override string Content(string contentPath)
    {
        if (String.IsNullOrEmpty(contentPath))
        {
            return null;
        }

        if (contentPath[0] == '~')
        {
            PathString hostPathString = new PathString("//" + _contentSubdomain + "."
                + HttpContext.Request.Host);
            PathString applicationPathString = HttpContext.Request.PathBase;
            PathString contentPathString = new PathString(contentPath.Substring(1));

            return HttpContext.Request.Scheme + ":"
                + hostPathString.Add(applicationPathString).Add(contentPathString).Value;
        }

        return contentPath;
    }
}

Once again it makes an important assumption - that it's enough to prefix current request host with the subdomain in order to create correct URL.

The second thing is IUrlHelperFactory implementation. This service is responsible for creating IUrlHelper implementation instances. It has a single method. One important thing is to keep the same caching capability as in default implementation, otherwise it can cause a performance regression.

public class ContentSubdomainUrlHelperFactory : IUrlHelperFactory
{
    private string _contentSubdomain;

    public ContentSubdomainUrlHelperFactory(string contentSubdomain)
    {
        if (String.IsNullOrWhiteSpace(contentSubdomain))
        {
            throw new ArgumentNullException(nameof(contentSubdomain));
        }

        _contentSubdomain = contentSubdomain;
    }

    public IUrlHelper GetUrlHelper(ActionContext context)
    {
        // Parameters validation removed for brevity.
        ...

        object value;
        if (context.HttpContext.Items.TryGetValue(typeof(IUrlHelper), out value)
            && value is IUrlHelper)
        {
            return (IUrlHelper)value;
        }

        IUrlHelper urlHelper = new ContentSubdomainUrlHelper(context, _contentSubdomain);
        context.HttpContext.Items[typeof(IUrlHelper)] = urlHelper;

        return urlHelper;
    }
}

Replacing the default implementation should be done after calling AddMvc().

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        ...

        services.AddMvc();

        services.Replace(new ServiceDescriptor(typeof(IUrlHelperFactory),
            new ContentSubdomainUrlHelperFactory("static")));
    }

    ...
}

Running the application now would result in it almost working...

Enabling CORS for static files

The not working part are dynamically requested resources (for example fonts in case of Bootstrap or lazy loaded scripts). Introduction of dedicated subdomain has changed those requests into cross-origin ones. This can be fixed by adding CORS middleware to the branch.

public class Startup
{
    ...

    private static void ConfigureStaticSubdomain(IApplicationBuilder app)
    {
        app.UseCors(builder =>
        {
            builder.WithOrigins("https://example.com");
        });

        app.UseStaticFiles();
    }
}

Now everything works as it should.

As mentioned, the above implementation is simplified based on few assumptions. In most cases those should be true, but if not it shouldn't be hard to modify the code to handle more complex ones.