Whether you have an app with just a few users or millions of users per day like Agoda, you can always improve a user’s experience by making your application more snappy. In the case of very high traffic websites in the cloud, this could also mean the difference between having 10 app instances or 4; leading to some juicy cost savings. During Agoda’s migration from ASP.NET 4.7.2 to ASP.NET Core 3.1, this is exactly what we wanted to keep in mind.
After a number of years working on high traffic web apps in ASP.NET and ASP.NET Core and most recently at Agoda, I realized that different businesses require different levels of optimization in different categories. So I decided to put together a list of server-side optimizations, ordered from easy low-hanging fruits, to more low-level micro-optimizations that can help you squeeze every drop of performance and scalability from your application setup.
1. Reduce the number of databases and external API calls your application has to make
I think for most, this is an obvious one. Database calls and API calls are notoriously slow by nature and some are so slow, they can be a deal-breaker for the app you are building. I suggest setting up analytics or logging to track how slow your database/API calls are and seeing whether you need to make those calls at all or at least reduce the number of cases you need to do this. Believe it or not I have seen this nightmare code in the past.
Assuming your API and Database communications are already used sparingly, the next step is to see if you can make those calls even more infrequent by using cache. ASP.NET Core offers IMemoryCache, which is easy to use and get started. IMemoryCache does have its pros and cons.
Pros: It’s extremely fast to store and retrieve, easy to use.
Cons: If you have multiple servers running the application, cache misses will be common, in these scenarios distributed caching is recommended. MemoryCache uses the server’s RAM, so be cautious how much data you put there.
2. Use Async versions of methods whenever possible
Let’s clear up one important misconception, async does not automatically make your application faster. In fact, in lower traffic web apps, you will sometimes see a small dip in performance (usually less than 1%), due to the introduction of state-machines. So what is the purpose of async/await? The answer is scalability.
Every request to your application is handled by a dedicated thread. In synchronous implementations, every time your application has to make a database call, API call, or any other I/O operation, the thread has to sit and wait for the external system to respond. Inefficient, isn’t it? Can’t we make use of that lazy thread somehow?
Quick note about threads in .NET: Threads are an OS abstraction through which we can schedule tasks to run on the CPU. These threads are expensive/slow to create, which is why .NET Framework uses a ThreadPool, that maintains a list of threads which the application can reuse. To optimize for performance, we want to avoid reaching an empty ThreadPool as much as possible.
This is where async/await comes in handy. By using async/await methods, we allow .NET Framework to put the executing thread back on the thread pool for further reuse, until it is needed again (when we get the response back from external I/O). This can significantly increase the maximum throughput of the server. It also opens up the door for further optimization, see next point.
3. Use your asynchronous methods wisely
The ability to launch a task without waiting, provided to us by the async/await pattern, allows to optimize even further. Take a look at the following example:
This type of code is very common; you need several pieces of data to proceed with your next operation. But, this code can be much more efficient. Let’s rewrite it like this:
This change looks simple, but makes a significant difference. Here, we are launching GetBasicDataAsync, GetRewardsDataAsync and GetPurchaseDataAsync without waiting for their results. This allows us to achieve a kind-of parallelism. We choose to await the results only when we need them. The rule of thumb here, is to not await as soon as you launch an async operation, await only when you need the result.
For a deep-dive into async/await topics take a look at Stephen Cleary’s blog.
4. If you need to use HttpClient, use it properly
HttpClient class in .NET makes it easy to make calls to various APIs, but too often it isn’t used properly. Usually like this:
using(var client = new HttpClient())
//do something with http client
The problem with this approach, is that under load this application will exhaust the number of available sockets very quickly, this is because we keep opening and closing the connections rapidly. Using this approach cripples the throughput of the server.
A better approach would be to reuse HttpClient when contacting the same server. This will allow the application to reuse the sockets for multiple requests. If you are using at least .NET Core 2.2, the easiest way to handle this, is using HttpClientFactory. Simply inject IHttpClientFactory into your service and it will take care of the above issues behind the scenes.
For a deeper explanation on this topic, take a look at this blog post.
5. If you use Razor pages, make use of <Cache> Tag Helper
Tag helpers were introduced in ASP.NET core and are a more convenient version of Html Helpers. One of these helpers is the <cache> tag; it allows for an easy way to render-cache parts of the page straight to MemoryCache. Here are some simple examples:
These tag helpers provide plenty of options, so if you are using razor pages, you should probably look into what they have to offer.
6. Consider using gRPC for calling your backend services
If your web application makes REST API calls to various (micro)services, it may be beneficial to switch your mode of communication to gRPC. Developed by Google, this model combines the benefits of the new HTTP/2 protocol and binary payload for improved communication performance.
In a nutshell, there are two reasons for this improvement. First, HTTP/2 allows for far better connection management; multiple calls to the same server can be made using a single connection, improving server throughput under stress. Second, due to the binary nature of the payload, serialization/deserialization cost is almost non-existent, which reduces CPU overhead for each call.
7. Reducing Memory Allocations
Up to this point, we have been trying to optimize how quickly and efficiently our web application communicates with other systems. Now, let’s take a look at how we can make our application tick a little bit faster and a little bit smoother.
Let’s talk about Garbage Collection. Nobody likes to do it. Computers also do not like to do it. When Garbage Collection happens, CPU has to do work and even worse, most operations are put on a brief pause so that CPU can take out the trash. To squeeze more performance out of our servers, we want to minimize the length and frequency of these hiccups. When optimizing Garbage Collection it’s important to keep a few principles in mind:
- When server memory is running low, garbage collection will happen more frequently and more aggressively
- The longer an object stays alive in memory, the less frequently it will be checked upon (whether it still needs to be in memory or not). This can cause RAM usage to stay higher for longer
- When a large number of objects is dereferenced in memory. Garbage Collector will compact the memory to reduce fragmentation. This process is slow, and we should aim to avoid this
We can simplify the above principles into something more digestible:
“Create fewer objects, and try to keep them small”
There are a number of ways of doing this, here is a list of my favorites:
Preallocate Generic Collections: If you use C#, you use Lists<> and Dictionaries<>. They are convenient and easy to use, but they are not magic; behind the scenes Lists use Arrays and Dictionaries use HashTables. What do Arrays and HashTables have in common? They are immutable… But wait, if they are immutable how can I use `coolGuyList.Add(myFriend)`? They are indeed immutable, so a lot of the times when you call Add(), the runtime creates a new bigger array and copies the objects over from the old smaller array. (Last time I checked, the runtime doubles the size of the underlying array every time it creates a bigger one; something along the lines of 0, 4, 8, 16…). Imagine you are populating a big List in a `foreach` loop; sounds kind of inefficient right?
Thankfully there is an easy way to optimize. Instead of the runtime guessing how big of an array to allocate, we can simply tell the runtime how big of an array we are expecting:
There are some cases where we simply cannot predict how big the Array/HashTable needs to be, but from my experience, in about half of the cases you can.
Consider using Structs over Classes: This is a trickier optimization that will need some bench-marking/stress-testing. In some cases when a large number of objects is created and destroyed, it may be beneficial to switch classes to structs because they are value types and do not have pointers to maintain, making Garbage Collector’s life a bit easier.
Reuse Lists and Dictionaries: Although, this is not too common in code, sometimes we need multiple instances of a List, Dictionary, etc…to complete a certain task, and sometimes these collections are Generics of the same type. In such cases once we are done with one collection we can simply call `list.Clear()` and reuse the same collection again for another operation. This can improve performance because Clear() does not shrink the underlying array, but de-references the contained items allowing us to reuse the underlying array for our further needs.
These are some of the more successful techniques I have used in my experience, most of which were implemented during Agoda’s migration effort to ASP.NET core. Pleasant coding my friends!