Improving API Performance in ASP.NET Core
By FoxLearn 1/2/2025 3:14:12 AM 45
As applications grow and handle more data, ensuring that APIs perform well under heavy load becomes essential.
Why Is Optimizing Resource Usage Important for Web APIs?
Web APIs play a central role in modern applications, especially in systems with complex data interactions or large volumes of requests.
Optimizing APIs ensures that they are:
- Faster and more responsive
- Able to handle increased load without resource exhaustion
- Capable of scaling horizontally to meet future demands
Optimized APIs lead to a better user experience, reduced resource usage, and improved overall system performance.
Best Practices for Optimizing API Performance
1. Asynchronous Requests for Scalability
Asynchronous communication enables the server to handle thousands of simultaneous requests efficiently. Instead of waiting for one request to complete before processing another, ASP.NET Core can manage a small pool of threads to handle many requests concurrently.
Let's look at the code below, where the request is being handled synchronously:
[HttpGet("orders")] public ActionResult<List<Order>> GetOrders() { var orders = _context.Orders .Where(o => o.Status == OrderStatus.Pending) .ToList(); return Ok(orders); }
In this synchronous version, each request to the GetOrders
endpoint blocks a thread from the server's thread pool. If there are many simultaneous requests, the thread pool could become exhausted, leading to performance degradation or service downtime. Since there is no asynchronous code here, the server waits for one request to finish before processing another.
To fix this issue, we can rewrite the endpoint to use asynchronous communication:
[HttpGet("orders")] public async Task<ActionResult<List<Order>>> GetOrders() { var orders = await _context.Orders .Where(o => o.Status == OrderStatus.Pending) .ToListAsync(); return Ok(orders); }
In the updated version, we use the async
and await
keywords, and importantly, we replace ToList()
with the asynchronous method ToListAsync()
.
Asynchronous communication is particularly beneficial in high-load scenarios, where many simultaneous requests are expected. It helps prevent bottlenecks, ensuring that the server remains responsive even under heavy traffic.
2. Use Pagination for Large Data Collections
When APIs need to transfer large datasets, pagination can help by limiting the amount of data returned at once. By using the Skip()
and Take()
methods in LINQ, you can fetch only a subset of the records, which reduces memory consumption and improves performance.
[HttpGet("items/{skip}/{take}")] public async Task<ActionResult<List<Item>>> GetItemsPaginated(int skip = 0, int take = 10) { var items = await _context.Items.Skip(skip).Take(take).ToListAsync(); return Ok(items); }
3. Leverage AsNoTracking() for Read-Only Queries
AsNoTracking()
is an extension method in Entity Framework Core that improves performance by disabling entity tracking. By default, EF Core tracks changes to entities so it can synchronize them with the database when SaveChanges()
is called. This tracking consumes memory and processing power.
When AsNoTracking()
is used, EF Core disables this tracking, leading to faster queries and lower memory usage since the DbContext
doesn't need to track the returned entities. This is especially useful for scenarios where you're only reading data and don't need to modify it.
For instance, consider the following code that retrieves a list of customers:
[HttpGet("customers")] public async Task<ActionResult<List<Customer>>> GetCustomers() { var customers = await _context.Customers .AsNoTracking() .Where(c => c.IsActive) .OrderBy(c => c.LastName) .ToListAsync(); return Ok(customers); }
In this example, we use AsNoTracking()
to fetch a list of active customers, ensuring that EF Core doesn't track the entities and thus optimizes performance. Since we're only reading the data (not modifying it), this is a perfect use case for AsNoTracking()
.
However, if you need to modify the entities, do not use AsNoTracking()
, as it disables the change tracking required for SaveChanges()
to apply updates. For instance:
[HttpPost("update-customer")] public async Task<ActionResult> UpdateCustomer([FromBody] Customer customer) { // Don't use AsNoTracking() here since we need to track changes _context.Customers.Update(customer); await _context.SaveChangesAsync(); return NoContent(); }
In this update scenario, AsNoTracking()
should be avoided because we need EF Core to track changes to apply them to the database.
4. Minimize Network Round Trips
Avoiding unnecessary network round trips means consolidating data retrieval into a single call when possible, rather than making multiple requests and combining their results later. This can greatly reduce server load and improve performance.
Consider the following code:
[HttpGet("products")] public async Task<ActionResult<List<Product>>> GetProducts() { var products = await _context.Products.AsNoTracking().ToListAsync(); return Ok(products); } [HttpGet("categories-with-products")] public async Task<ActionResult<List<Category>>> GetCategoriesWithProducts([FromQuery] List<int> productIds) { var categories = await _context.Categories .AsNoTracking() .Where(c => productIds.Contains(c.ProductId)) .ToListAsync(); return Ok(categories); }
Here, we have two endpoints: one to fetch all products, and another to fetch categories related to those products. This results in two separate calls—first to get products and then to get categories based on the product IDs. This can be inefficient.
Instead, we can consolidate these operations into a single call like so:
[HttpGet("categories-with-products")] public async Task<ActionResult<List<Category>>> GetCategoriesWithProducts() { var products = await _context.Products .AsNoTracking() .ToListAsync(); var productIds = products.Select(p => p.Id).ToList(); var categories = await _context.Categories .AsNoTracking() .Where(c => productIds.Contains(c.ProductId)) .ToListAsync(); return Ok(categories); }
In this improved version, we retrieve both products and their associated categories in one call. The result is a more efficient endpoint, reducing the number of HTTP requests and simplifying the client-side logic. Consolidating calls in this way can prevent excessive use of resources and ensure that your API performs optimally.
5. Use Task Properly for Async Methods
Using async Task
in API endpoints allows the calling code to wait for the task to complete, ensuring that the request pipeline doesn't proceed until the asynchronous operation finishes. This is particularly useful for I/O-bound tasks like database queries or file uploads.
ASP.NET Core expects action methods to return a Task
when working with asynchronous operations, allowing the framework to manage the request pipeline effectively. When async void
is used, the expected pattern is broken, potentially leading to unpredictable behavior, like sending responses before the async operation is completed.
For example, instead of using async void
:
[HttpPut("/{id}")] public async void UpdateUser([FromRoute] int id, [FromBody] User user) { var existingUser = await _context.Users.FirstOrDefaultAsync(u => u.Id == id); if (existingUser != null) { existingUser.Name = user.Name; existingUser.Email = user.Email; await _context.SaveChangesAsync(); await Response.WriteAsync("User updated successfully"); } }
Use async Task
:
[HttpPut("/{id}")] public async Task UpdateUser([FromRoute] int id, [FromBody] User user) { var existingUser = await _context.Users.FirstOrDefaultAsync(u => u.Id == id); if (existingUser != null) { existingUser.Name = user.Name; existingUser.Email = user.Email; await _context.SaveChangesAsync(); await Response.WriteAsync("User updated successfully"); } }
In this updated example, the async Task
ensures that the API request correctly waits for the user update operation to complete before responding.
6. Optimize LINQ Queries
Using LINQ (Language-Integrated Query) not only improves efficiency but also results in cleaner and more readable code. By leveraging methods like .Where
, .Select
, .CountAsync
, and .Sum
, filtering and aggregation are done directly in the database query. This reduces the need for creating additional variables or iterating over large datasets to find specific values, optimizing performance.
For example, instead of manually iterating and filtering through data:
[HttpGet("orders/total")] public async Task<ActionResult<decimal>> GetTotalOrderAmount() { var orders = await _context.Orders.ToListAsync(); decimal totalAmount = 0; foreach (var order in orders) { if (order.Status == OrderStatus.Completed) { totalAmount += order.Amount; } } return Ok(totalAmount); }
You can simplify this with LINQ, executing the filtering directly in the database query:
[HttpGet("orders/total")] public async Task<ActionResult<decimal>> GetTotalOrderAmount() { decimal totalAmount = await _context.Orders .Where(o => o.Status == OrderStatus.Completed) .SumAsync(o => o.Amount); return Ok(totalAmount); }
7. Cache Frequently Accessed Data
Caching is a technique that can greatly enhance an application's performance by minimizing the need for repeated resource-intensive operations, such as querying a database.
In caching, the first request retrieves data from its original source and stores it in a cache. For subsequent requests, if the data is found in the cache, it is directly returned to the requester without querying the original source again.
This technique works best for data that does not change often but needs to be accessed frequently and quickly.
ASP.NET Core supports two primary caching strategies: in-memory caching and distributed caching.
In-memory caching is the simplest approach, utilizing the IMemoryCache
interface. This cache is stored in the memory of the web server, making it fast but limited to the server's capacity.
Here's an example of how to use in-memory caching in an ASP.NET Core application:
[HttpGet("get-users-cache-in-memory")] public async Task<ActionResult<List<User>>> GetUsersWithCacheInMemory() { const string CacheKey = "users_list"; if (!_memoryCache.TryGetValue(CacheKey, out List<User>? users)) { users = await _context.Users.ToListAsync(); var cacheEntryOptions = new MemoryCacheEntryOptions() .SetSlidingExpiration(TimeSpan.FromMinutes(15)); _memoryCache.Set(CacheKey, users, cacheEntryOptions); } return Ok(users); }
In this example, the application checks if the data (a list of users) is already in the cache using TryGetValue
. If it isn’t, a query to the database is made, and the result is stored in the cache with an expiration time. Future requests retrieve the data from memory directly, without querying the database.
The other approach to caching is distributed caching, which is ideal for applications that run on multiple servers and need to share cached data across them. Distributed caches are usually maintained in external services, such as Memcached or Redis.
ASP.NET Core provides several options for distributed caching, including the popular StackExchange.Redis
library for Redis. Redis needs to be installed on a server or can be accessed via Docker.
Here’s an example of using Redis for distributed caching:
[HttpGet("get-products-cache-distributed")] public async Task<ActionResult<List<Product>>> GetProductsWithCacheDistributed() { const string CacheKey = "products_list"; List<Product>? products = await _redisCache.GetStringAsync<List<Product>>(CacheKey); if (products == null) { products = await _context.Products.ToListAsync(); await _redisCache.SetStringAsync(CacheKey, products, new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(20) }); } return Ok(products); }
This example demonstrates how to use Redis for distributed caching. It checks whether the data is available in the cache using GetStringAsync
. If the data is not found, it queries the database and stores the result in Redis with an expiration time. Future requests will retrieve the data from Redis instead of querying the database.
Both in-memory caching and distributed caching offer performance improvements, with in-memory caching being faster due to its local nature, and distributed caching being more suitable for scaling applications across multiple servers.
8. Optimize Database Operations with JSON
Using JSON in relational databases can serve as an efficient way to boost API performance, particularly in cases where data manipulation is straightforward and doesn't require complicated business logic or advanced relationships.
While relational databases are typically designed to handle structured data and establish relationships between tables, modern relational systems, such as MySQL and PostgreSQL, offer support for JSON data types. PostgreSQL, for example, has a specialized JSONB type, which is optimized for storing and querying JSON data efficiently.
Inserting JSON directly into a database column simplifies many operations. Instead of creating a complex schema with numerous tables and relational links, you can store the JSON structure as-is. This is particularly useful when you need to frequently update or overwrite entire objects. Instead of running multiple SQL operations to update separate tables, you can delete the old JSON data and insert the new one in a single operation, resulting in faster and simpler processes, especially for large datasets.
Below is another example of handling JSON data in a relational database:
// Json User Data Service Class public class ServiceJsonUser { public int Id { get; set; } public string JsonUserData { get; set; } } // Endpoint that receives JSON user data [HttpPost("create-json-user")] public async Task<ActionResult> PostServiceJsonUser([FromBody] ServiceJsonUser serviceJsonUser) { await _context.ServiceJsonUsers.AddAsync(serviceJsonUser); await _context.SaveChangesAsync(); return NoContent(); }
Example JSON request body:
{ "jsonUserData": "{\"Id\": 1, \"Username\": \"jdoe\", \"Email\": \"[email protected]\", \"Profile\": {\"Age\": 28, \"Country\": \"USA\", \"Interests\": [\"Music\", \"Traveling\"]}, \"CreatedAt\": \"2025-01-02T09:00:00\"}" }
In this example, the JsonUserData
field is populated with a JSON object that contains user details. This JSON data is then inserted directly into the database, eliminating the need for creating separate database tables or entities.
9. Optimize Code Efficiency
Review and optimize your code for unnecessary operations, such as loading all data into memory before filtering. In many cases, seemingly small inefficiencies can lead to excessive resource usage.
[HttpGet] public async Task<ActionResult<IEnumerable<User>>> GetUsers() { IEnumerable<User> users = await _context.Users.ToListAsync(); var result = users.Where(user => user.IsActive).ToList(); return Ok(result); }
In this code, we first load the entire list of users, then filter out the inactive ones. Additionally, we use the IEnumerable<>
interface and convert it to List<>
, which introduces unnecessary overhead.
To optimize this, we can modify the code like this:
[HttpGet] public async Task<ActionResult<List<User>>> GetUsers() { List<User> activeUsers = await _context.Users .Where(user => user.IsActive) .ToListAsync(); return Ok(activeUsers); }
In the optimized version, we directly apply the filter to the database query, avoiding the need to load all users into memory first. Moreover, we eliminate unnecessary conversions by using the List<>
type throughout.
Optimizing web APIs is key to creating scalable and efficient applications. By adopting best practices such as asynchronous requests, pagination, caching, and optimizing database queries, developers can significantly enhance API performance.
- How to use Brotli for response compression in ASP.NET Core
- How to use SignalR in ASP.NET Core
- How to use the Dapper ORM in ASP.NET Core
- How to enable CORS in ASP.NET Core
- How to implement HTTP.sys web server in ASP.NET Core
- How to use File Providers in ASP.NET Core
- How to use policy-based authorization in ASP.NET Core
- How to enforce SSL in ASP.NET Core