Performance
Most slow APIs are slow for the same few reasons: blocking calls, too much data, repeated work and no caching. Fix those and a Web API 2 service on IIS can handle a lot of traffic.
On this page · 6 min · Advanced
On this page
- Learning objectives
- Explanation
- Syntax
- Code example
- Step by step
- Real-world use
- Common mistakes
- Best practices
- Remember this
- Practice exercise
- Mini quiz
- Related topics
- My notes
- Reading time
- 6 min
- Difficulty
- Advanced
- Updated
- Oct 28, 2026
Related tutorials
Learning objectives#
- Keep IIS threads free with async database calls
- Write lean EF6 queries
- Cache repeated results with MemoryCache and HTTP headers
- Shrink responses with paging, projection and compression
Explanation#
Async all the way: await ToListAsync(), OpenAsync(), HttpClient calls. Blocking with .Result ties up IIS threads and can deadlock under ASP.NET’s synchronisation context.
Lean queries: AsNoTracking for reads, Select only the columns you need, Include or projection to avoid N+1 queries, filters before ToListAsync, and indexes on filtered columns.
Never return unbounded lists. Page everything, and cap page sizes.
Cache data that changes rarely (categories, settings) in System.Runtime.Caching.MemoryCache with an expiry. Add Cache-Control headers so browsers and CDNs can cache public responses too.
Turn on IIS dynamic compression for application/json: JSON often shrinks by 70–90%. Remove the XML formatter and ignore nulls to keep payloads small.
Reuse HttpClient (one static instance or a shared handler) — creating one per request exhausts sockets. Measure first: logs with timings, SQL execution plans and Application Insights show where time really goes.
Syntax#
<!-- Web.config: compress JSON responses (needs the IIS Dynamic Content Compression feature) -->
<system.webServer>
<urlCompression doStaticCompression="true" doDynamicCompression="true" />
<httpCompression>
<dynamicTypes>
<add mimeType="application/json" enabled="true" />
<add mimeType="application/json; charset=utf-8" enabled="true" />
</dynamicTypes>
</httpCompression>
</system.webServer>
Code example: Caching, lean queries and cache headers#
using System;
using System.Data.Entity;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Runtime.Caching;
using System.Threading.Tasks;
using System.Web.Http;
using ShopApi.Data;
using ShopApi.Models;
public class CatalogController : ApiController
{
private static readonly MemoryCache Cache = MemoryCache.Default;
private static readonly HttpClient Http = new HttpClient(); // reused, never per request
private readonly ShopDbContext _db;
public CatalogController(ShopDbContext db) { _db = db; }
// Rarely changes → cache on the server for 10 minutes and in browsers for 5
[HttpGet, Route("api/categories")]
public async Task<HttpResponseMessage> Categories()
{
var categories = Cache.Get("categories") as string[];
if (categories == null)
{
categories = await _db.Products.AsNoTracking()
.Select(p => p.Category).Distinct().OrderBy(c => c)
.ToArrayAsync();
Cache.Set("categories", categories, DateTimeOffset.UtcNow.AddMinutes(10));
}
var response = Request.CreateResponse(HttpStatusCode.OK, categories);
response.Headers.CacheControl = new CacheControlHeaderValue { Public = true, MaxAge = TimeSpan.FromMinutes(5) };
return response;
}
// Lean query: no tracking, only needed columns, bounded result
[HttpGet, Route("api/products/cheap")]
public async Task<IHttpActionResult> Cheap()
{
var items = await _db.Products.AsNoTracking()
.Where(p => p.Price < 100)
.OrderBy(p => p.Price)
.Take(50)
.Select(p => new ProductDto { Id = p.Id, Name = p.Name, Price = p.Price, Category = p.Category })
.ToListAsync();
return Ok(items);
}
// Outgoing calls: async and with a shared HttpClient
[HttpGet, Route("api/exchange-rate")]
public async Task<IHttpActionResult> ExchangeRate()
{
string json = await Http.GetStringAsync("https://rates.example.com/inr-usd");
return Ok(json);
}
}
GET /api/categories (first call) → SQL runs (~40 ms) Cache-Control: public, max-age=300 GET /api/categories (next 10 min) → served from MemoryCache (<1 ms), no SQL GET /api/products/cheap → one SELECT TOP (50) with 4 columns, no change tracking With IIS dynamic compression on: a 180 KB JSON list arrives as ~22 KB (Content-Encoding: gzip).
This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.
Step-by-step explanation#
- MemoryCache stores the category list for 10 minutes, so repeated requests skip the database.
- Cache-Control lets browsers and CDNs reuse the response for 5 minutes without calling the API.
- The cheap-products query is bounded (Take(50)), read-only (AsNoTracking) and narrow (Select).
- One static HttpClient is shared by all requests, avoiding socket exhaustion.
- IIS compression (configured in Web.config) shrinks JSON on the wire.
Real-world use case#
A product API slowed down during a sale. Logs showed one endpoint loading full product entities with lazy-loaded reviews (N+1). Switching to a projected, paged query and caching categories cut the average response time from 1.2 s to 60 ms.
Common mistakes#
Each instance holds sockets in TIME_WAIT for minutes. Under load the server runs out of ports. Reuse one instance.
Caching “my orders” under one key shows one user’s data to another. Include the user id in the key, or do not cache.
Guessing wastes time. Measure with timing logs, SQL Profiler/Query Store and Application Insights first.
Best practices#
- Async for every I/O call
- AsNoTracking + Select + paging for reads
- Cache stable data with expiries
- Enable JSON compression in IIS
Remember this#
Async I/O, lean queries, bounded results.
Cache what rarely changes; never mix users’ data.
One HttpClient for the whole app.
Practice exercise#
Your task: Cache the result of an expensive GetDashboardStatsAsync() for 30 seconds in MemoryCache, using a helper GetOrAddAsync(key, factory, seconds).
Need a hint?
Show solution
using System;
using System.Runtime.Caching;
using System.Threading.Tasks;
public static class CacheHelper
{
private static readonly MemoryCache Cache = MemoryCache.Default;
public static async Task<T> GetOrAddAsync<T>(string key, Func<Task<T>> factory, int seconds) where T : class
{
var cached = Cache.Get(key) as T;
if (cached != null) return cached;
var value = await factory();
Cache.Set(key, value, DateTimeOffset.UtcNow.AddSeconds(seconds));
return value;
}
}
// var stats = await CacheHelper.GetOrAddAsync("dashboard", () => GetDashboardStatsAsync(), 30);
Mini quiz#
Why should a Web API 2 action await database calls instead of using .Result?
Want more? Practise all ASP.NET Web API MCQs · ASP.NET Web API interview questions
My notes#
Saved in this browser.