Next.js meets ASP .NET Core — a story of performance and love at long tail

David Nissimoff
7 min readMay 31, 2022

--

If you are a frontend developer, chances are you have heard of or you actively use Next.js. And if you are a backend developer (or not), surely Node.js more than rings a bell. Afterall, if one wants to host a Next.js app, clearly one would do it on Node.js or some cloud provider that runs it on your behalf, right? Well…

Building the Next.js web app then running the ASP .NET Core server

A hint of where we are headed…

Measured throughput was ~6–7x greater on ASP .NET Core; P99 latency was ~2–4x better on ASP .NET Core

Is it a fair comparison? Not really. Read on to learn more…

Associated Github repo: davidnx/NextjsStaticHosting-AspNetCore: Next.js apps on ASP .NET Core without Node.js (github.com)

A step back before we get to the fun part…

The first thing to know about modern web app frameworks like Next.js is that web page interactivity does not require an interactive server with dynamic api’s. One powerful capability of Next.js, Static HTML Export, compiles your entire web app to simple files that can be served from anywhere (examples abound which only serves static files and no dynamic content — yet the resulting pages can be complete dynamic web apps).

Web page interactivity does not require an interactive server with dynamic api’s

In fact, one of the tricks Next.js employs to achieve optimal page load performance, SSG (Static Site Generation), consists of statically generating content at build time — HTML, embedded CSS and all — and just serving this when a client loads the page. This has benefits across the board: pages load faster; Search Engines gain better visibility into the actual rendered content; etc.

Why Node.js?

There are scenarios where the final contents cannot be produced at build time. This includes web apps leveraging SSR (Server-Side Rendering) or other Next.js features listed under Static HTML Export unsupported features. In those cases, there’s no way but to render the web app server-side at request time, and then serve the final contents to the client. Node.js is the technology that enables running the same JavaScript, or portions of it, that would run on the client, but on the server.

But do we need SSR?

Software Engineering is often about finding the right balance among competing trade-offs. In many cases, SSR may absolutely be the right solution. This article is for all other cases. Mind you, this is hardly a tough constraint. A lot can be done with fully static content! Recall for a moment that when you install an app on your mobile device, a static app package is installed on your device. A statically exported Next.js app isn’t much different.

When you install an app on your mobile device, a static app package is installed on your device. A statically exported Next.js app isn’t much different

Fine. Let’s host static files. Are we done now?

Almost. There is one additional concern we need to deal with, and that is Dynamic Routes.

Imagine a Next.js application consisting of the following pages:

  • /pages/index.js
  • /pages/post/[pid].js

When statically exported to HTML using npx next export, the exported output will contain the following entry-point HTML files:

  • /out/index.html
  • /out/post/[pid].html

When a browser issues a request for /, the server is expected to return the contents of /out/index.html. Similarly, a request for /post/123 is supposed to return the contents of /out/post/[pid].html. As long as the appropriate initial HTML is served for the incoming request paths, Next.js takes care of the rest, and will rehydrate the page on the client-side providing full React and interaction capabilities.

However, if the wrong page is served (e.g., if /out/index.html were served on a request for /post/123), rehydration won't work (by design!). The client will render the contents of /pages/index.js page even though the URL bar will say /post/123, and it will NOT rehydrate the contents that were expected — code running in the browser at that time in fact would not even know that it was supposed to be showing the contents of a different page.

SSG-safe rehydration

Beware of an important caveat with this approach. Next.js expects, reasonably so, that the first render of the page JavaScript should produce the exact same tree as had been produced statically. This poses a challenge when dynamic content is to be rendered for dynamic routes. Imagine for example that the page contents will include an h1 tag with contents that include a parameter specified via a dynamic route. You must be careful to ensure the tree is consistent with the statically generated contents on first render.

Luckily, this is easy to achieve by creating a state variable that gets updated on mount with useEffect. The sample app showcases exactly this scenario (source).

How do we ensure the right contents are served?

It’s quite simple actually. We just need the web server to understand what routes should map to which statically-generated files. Recapping the previous example, a request to /post/123 needs to produce the contents of /out/post/[pid].html. As long as routes are created with the right patterns, everything else falls into place.

Now all we need is a high performance Web Server with custom routing capabilities…

ASP .NET Core to the rescue

Full disclosure: No, I didn’t just randomly pick ASP .NET Core. I happen to work for Microsoft where we use ASP .NET Core extensively for high scale, mission critical web services that power multi-billion dollar businesses. The developer experience and performance make it a great choice for such applications. That said, this is a personal project and unrelated to my day-to-day job.

ASP .NET Core is among the best performing general-purpose web servers in wide use today, and Microsoft has been investing heavily on pushing this further. It being open-source and cross-platform makes it is a natural choice to host modern web apps at scale.

Show me the code

It’s about time you asked: https://github.com/davidnx/NextjsStaticHosting-AspNetCore. It is also available as a Nuget package.

This package works by transforming the statically compiled Next.js output directory structure into ASP .NET Core Endpoints (see: Routing in ASP .NET Core). Dynamic Routes are converted to parameterized route templates. To illustrate, page /pages/post/[pid].js from the previous example gets represented by a new Endpoint with route template/post/{pid} which ASP .NET Core understands. Requests matching this endpoint serve the desired file with a standard StaticFileMiddleware (source).

Local development experience

Next.js offers HMR (Hot Module Replacement), which significantly speeds up development. To maintain full fidelity to the usual npm run dev experience, this project deliberately makes no attempt to replace it. Instead, the library is configurable to run in development mode, such that it forwards requests appropriately to the local Next.js dev server with full fidelity by using YARP (“A toolkit for developing high-performance HTTP reverse proxy applications”). The sample client-server app in the repo shows how this is configured.

Is it any faster? Benchmark time

Measured on Windows 11 version 21H2 (OS Build 22000.675) on an AMD Ryzen 9 5900HX using latest LTS releases of .NET 6 (6.0.5) and Node.js (16.15.0) as of 5/27/2022. Tests were run against localhost on raw HTTP using Apache Bench Version 2.3 Revision: 1879490 as the test client.

The ASP .NET Core project was published in Release configuration and executed without custom tweaks. The Next.js app was run with npm run start without custom tweaks (see: docs). Each scenario was run 3 times, and the median run is shown.

Measured throughput was ~6–7x greater on ASP .NET Core; P99 latency was ~2–4x better on ASP .NET Core.

Scenario 1: 10 concurrent, 50k requests

Command line: ab -c 10 -n 50000 -k http://localhost:PORT/post/1

 Hosting stack    Avg (ms)    P99 (ms)
---------------- ----------- ------------
ASP .NET Core 0.469 1
Node.js 3.355 5

Scenario 2: 100 concurrent, 100k requests

Command line: ab -c 100 -n 100000 -k http://localhost:PORT/post/1

 Hosting stack    Avg (ms)    P99 (ms)
---------------- ----------- ------------
ASP .NET Core 4.957 22
Node.js 29.652 41

Is this a fair comparison?

Not really, for a few reasons. But it is still informative:

  • The capabilities of the two stacks are not equivalent, and the ASP .NET Core hosting stack enabled by this project does not support SSR content. To a large degree, we are comparing apples and oranges. On the other hand, entire apps can be built without needing SSR. In those cases, the additional capabilities of running in Node.js are unused
  • Only measuring the time to load the HTML for a page. Loading other static content (js, css, images, etc.) may account for longer delays in observed page load performance in practice, and those aren’t taken into account in these tests
  • Not necessarily following best practices for production hosting, though the setup followed the official guidance for both Next.js and ASP .NET Core without additional tweaks in either
  • Run on Windows, whereas each stack could exhibit different perf characteristics on a different OS

Where do we go from here?

Anywhere! Or nowhere, really… If you are running your Next.js apps on Node.js and/or the cloud provider of your choice, you likely have no reason whatsoever to change.

I expect this article and associated code to be most useful in the following cases:

  • If you already have a backend written on .NET technologies, and you are looking to add a modern React-based web app to your solution. Instead of spinning up a new (micro-?)service to host Node.js, perhaps you’d be interested in hosting the web app alongside your existing ASP .NET Core services.
  • If you already host a Next.js web app and have no need for dynamic server-generated content and you want to extract the last drop of performance and/or reduce your cloud spend
  • Simply learning more about how Next.js Static HTML Export works
  • Simply learning more about ASP .NET Core and YARP

Thoughts? Questions? Concerns? Leave a comment and/or create issues on Github and I will do my best to address them. Thank you for reading!

--

--

David Nissimoff

Principal Software Architect @Microsoft happiest when I’m learning new things. Opinions are mine and not my employer’s.