Alex KlausDeveloper  |  Architect  |  Leader
Hosting SPA + .NET API solution. Caching and environment variables
21 March 2021

.NET + SPA hosting

When a dev team maintains a Single-Page Application (SPA) front-end and .NET Web API back-end, what are the options to host the two beasts?

1. Hosting options

It’d be a no-brainer for just a Web API – use solutions like Azure App Service. An open-and-shut case. But when you bring a SPA into the picture, there are usually two options:

Option 1. Hosting SPA independently from the Web API.

Preferably, on a Content Delivery Network (CDN) that lets you use your domain name, has configurable caching policies, and all other modern attributes like 100% SLA, etc. For Azure clients, a Static Web App can be a decent choice.

If you need to scale the front-end independently from the back-end, then CDN is a go-to option. But hold your horses for a moment. We’re talking about an enterprise app with a substantial portion of returned users where the SPA assets often come from the browser cache.

Still need to scale independently? OK, then, what about the downsides? Though none of them is a deal-breaker, they all add up and may outweigh the pros of CDN.

The biggest one is a non-immutable front-end bundle.

As any part of the solution, SPA relies on environment variables (e.g. the URL of the Web API) that differ between your environments (usually, development, staging/testing and production).

Of course, you may decouple the variables to a separate JSON file and have a round trip to resolve them. Suboptimal though. The delay here is mostly coming from two places:

  • another HTTP request (which is less of a problem on HTTP/2);
  • running a bit extra of JavaScript on the startup due to sending that request occurs after bootstrapping the app, and then it has to alter the state based on the received variables.

Rebuilding the front-end for each environment for the sake of injecting variables is even less appealing (think of additional points of failure it introduces).

We can add a bunch of minor concerns like

  • desynchronised deployment of the two essential parts of a single solution can be sometimes tricky and, if not done right, then leads to intermittent errors when the two get out of sync;
  • extra resources (human hours) to maintain the distributed infrastructure (CDN brings one more point of failure that sooner or later will require attention);
  • a need for an additional OPTIONS request each time to satisfy the browser’s CORS feature (though latency over HTTP/2 is negligible).

These concerns may lead us to consider another option.

Option 2. Hosting SPA from the Web API

Kestrel web server can host the front-end along with the .NET Web API. That would require a bit of code in your back-end project.

This solution quickly eliminates the last 3 concerns:

  • not splitting vitals part of one solution simplifies deployment;
  • less moving parts lead to easier maintenance;
  • CORS is not involved, as the front-end is hosted on the same domain.

However, achieving the two goals below need a deeper analysis:

  1. immutability of the front-end bundle;
  2. a custom caching policy for various front-end parts.

2. SPA hosted by .NET Web API

Starting from .NET 2.x (still the case for .NET 5), there are UseStaticFiles, UseSpa and UseSpaStaticFiles extensions that do all the heavy lifting. Though, the difference between them isn’t all-clear from the official docs.

A brief summary would be:

  • UseSpa serves the default page (index.html) and redirects all requests there.
  • UseStaticFiles serves other than index.html static files under the web root folder (wwwroot, by default). Without this method, Kestrel would return index.html in response to all static content requests
  • UseSpaStaticFiles does a similar thing, but it requires ISpaStaticFileProvider to be registered to resolve the location of the static files. You need it if the static files are NOT under the default wwwroot folder.

For simplicity, let’s host SPA from wwwroot and leverage UseSpa and UseStaticFiles methods to set up caching and inject environment variables into an already built front-end bundle.

2.1. Caching

SPA caching rules boil down to

  1. Cache static assets (JavaScript files, images, etc.) forever, as those files have unique names generated on each build (thanks to WebPack).
  2. Prevent caching the index.html as it’s usually a tiny file keeping all the references to other static assets with unique names. This way, the client would always download a new bundle of JS & CSS files on releasing a new version.

Nowadays, the minimum set of HTTP response headers to disable caching requires:

Cache-Control: no-cache, no-store, must-revalidate, max-age=0

If you need to support old browsers then check out this debate on StackOverflow to adjust the rules. The key evolution points were:

As index.html is hosted by UseSpa method, so disabling cache happens this way:

app.UseSpa(c => c.Options.DefaultPageStaticFileOptions = new StaticFileOptions
{
	OnPrepareResponse = ctx =>
	{
		var headers = ctx.Context.Response.GetTypedHeaders();
		headers.CacheControl = new CacheControlHeaderValue
		{
			NoCache = true,
			NoStore = true,
			MustRevalidate = true,
			MaxAge = TimeSpan.Zero
		};
	}
})

Then to force caching other static assets, we need to add this header to the HTTP response:

Cache-Control: max-age=31104000

where max-age is set to an arbitrarily large number (e.g. a year, as per the example above).

Remember that hosting of non-index.html files is controlled by UseStaticFiles method, so the code would look like this:

app.UseStaticFiles(new StaticFileOptions
{
	OnPrepareResponse = ctx =>
	{
		var headers = ctx.Context.Response.GetTypedHeaders();
		headers.CacheControl = new CacheControlHeaderValue { MaxAge = TimeSpan.FromDays(12*30) };
	}
})

Caching is sorted now.

2.2. Passing parameters to immutable front-end bundle

As pointed out above, preserving a well-tested and reliable front-end bundle is worth the effort. Then how hosting the front-end from the .NET may help inject environment variables? Of course, if we still hate doing a round trip to fetch variables in a JSON file when the SPA has been bootstrapped.

Option 0. Injecting during deployment (preferable)

It would be the preferable option, but the implementation depends on the deployment tools. Some (e.g. Octopus Deployment) can replace variables in index.html and minimised JavaScript files.

Strictly speaking, injecting variables during deployment would break the immutability of the bundle. Just a little bit, all the JS/CSS/etc. files remain untouched. But I’d turn a blind eye to it.

Option 1. Transforming the ‘index.html’ served by .NET

I’m not a fan of run-time index.html manipulation in .NET, so feel free to jump over to ”Option 2” below.

However, we can define the environment variables in a JavaScript block in the index.html. There are multiple ways of doing it, e.g.

<script type='text/javascript'>
    window.importantVariable = '{#importantVariable}';
</script>

Then the index.html is served by .NET where the {#importantVariable} marker gets transferred to the environment value (e.g. taken from appsettings.json).

The most common implementation of modifying the index.html in .NET is adding a fallback MVC controller that would read and modify the file:

public class FallbackController : Controller
{
	public async Task<IActionResult> Index()
	{
		string content = string.Empty;
		using (var indexHtmlReader = System.IO.File.OpenText(_indexHtmlPath))
		{	// Reads 'index.html'
			content = await indexHtmlReader.ReadToEndAsync();
		}

		return new ContentResult
		{
			Content = content.Replace("{#importantVariable}", "Value"), // Replaces the variable
			ContentType = "text/html"
		};
	}
}

and configuring the fallback in Startup.cs:

app.UseWhen(context => !context.Request.Path.Value.StartsWith("/api"),
	builder => {
		app.UseSpa(c => {...});
		app.UseMvc(routes => {
			routes.MapSpaFallbackRoute(
				name: "spa-fallback",
				defaults: new { controller = "Fallback", action = "Index" });
	});
});

Alternatively, you could do it all right in UseSpa method and write a modified index.html into the output… But let’s jump to a more exciting solution.

Option 2. Return ‘index.html’ along with a short-lived cookie (hacky way)

A less invasive solution would be returning a short-lived cookie along with index.html that gets read when the SPA application starts.

Setting a short expiration period is a neat addition, as cookies get sent to the back-end on each XHR request. The expiration period (e.g. 30 sec) should be big enough to bootstrap and kick off the SPA to read the cookie and small enough to prevent adding an unnecessary cookie to most of the server-side requests.

Playing with the expiration period does introduce some fragility, negligible but still. I’ve warned you it’s a hacky way :)

Just add 1 line to the above UseSpa:

app.UseSpa(c => c.Options.DefaultPageStaticFileOptions = new StaticFileOptions
{
	OnPrepareResponse = ctx =>
	{
		var response = ctx.Context.Response; 
		response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue
		{
			NoCache = true,
			NoStore = true,
			MustRevalidate = true,
			MaxAge = TimeSpan.Zero
		};
		// Here is our cookie:
		response.Cookies.Append("importantVariable", "Value", new CookieOptions { MaxAge = TimeSpan.FromSeconds(30) });
	}
})

Then reading the cookie in SPA on starting the app:

this.getCookie('importantVariable', 'default value');

private getCookie(name: string, defaultValue: string): string {
	const value = ('; ' + document.cookie).split(`; ${name}=`).pop()?.split(';').shift();
	return !!value ? decodeURIComponent(value) : defaultValue;
}

Note that cookies do not provide isolation by port. Not a biggie, but can cause raised eyebrows when in the local environment devs swap between running the front-end under Kestrel and the framework’s CLI / NodeJs.

Cool or not? Have your say in the comments below, on Reddit thread, Twitter or LinkedIn.

And check out YABT, my open source project at GitHub that does the trick with hosting SPA and passing parameters in cookies (direct link to Startup configuration and reading cookies in Angular).