When application traffic starts growing, the first instinct is usually to add more servers. In reality, scalability is far more complex than that.
I’ve seen applications that ran perfectly with 1,000 users completely fall apart when traffic suddenly increased. Interestingly, the ASP.NET Core application itself was rarely the first thing to fail.
The real bottlenecks were hidden in places most developers don’t think about until production starts burning.
If one million users suddenly arrive at your .NET application, here’s what will likely break first—and how to prevent it.
- Your Database Will Scream First
Consider a typical API endpoint:
[HttpGet(“{id}”)]
public async Task<IActionResult> GetUser(int id)
{
var user = await _context.Users
.FirstOrDefaultAsync(x => x.Id == id);
return Ok(user);
}
Looks harmless. Now imagine: 50,000 requests per minute where every request hits SQL Server and executes the same query. Eventually, connection pools get exhausted, query latency increases, CPU usage spikes, and requests begin timing out.
Better Approach: Cache Frequently Accessed Data
Adding Redis can reduce database traffic dramatically.
| public async Task<UserDto> GetUserAsync(int id) { string cacheKey = $”user:{id}”; var cachedUser = await _cache.GetStringAsync(cacheKey); if (!string.IsNullOrEmpty(cachedUser)) { return JsonSerializer.Deserialize<UserDto>(cachedUser); } var user = await _context.Users .Where(x => x.Id == id) .Select(x => new UserDto { Id = x.Id, Name = x.Name }) .FirstOrDefaultAsync(); await _cache.SetStringAsync( cacheKey, JsonSerializer.Serialize(user), new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) }); return user; } |
- Synchronous Work Will Block Your Application
Many applications perform expensive tasks directly inside requests.
| [HttpPost] public async Task<IActionResult> Register(UserDto model) { await _userService.CreateUser(model); await _emailService.SendWelcomeEmail(model.Email); return Ok(); } |
The user must wait for database operations, SMTP communication, and network latency. At scale, thousands of blocked requests create a bottleneck.
Better Approach: Queue the Work
A background worker handles the email later.
| [HttpPost] public async Task<IActionResult> Register(UserDto model) { await _userService.CreateUser(model); await _messageBus.PublishAsync( new SendWelcomeEmailEvent { Email = model.Email }); return Ok(); } |
Popular options: RabbitMQ, Azure Service Bus, Hangfire, Kafka
- Memory Usage Will Quietly Kill Your Servers
A common mistake is loading entire tables into memory, which works during development but fails spectacularly at scale:
| private static List<Order> Orders = new List<Order>(); // Or: var orders = await _context.Orders.ToListAsync(); |
At scale, memory usage grows, Garbage Collection becomes aggressive, and response times increase.
Better Approach: Use Pagination
Only load what you need.
| var orders = await _context.Orders .Skip(page * pageSize) .Take(pageSize) .ToListAsync(); |
- Session State Becomes a Disaster
Many applications still use in-memory sessions:
| HttpContext.Session.SetString(“UserName”, “John”); |
This works perfectly on one server. Then a load balancer enters the picture:
User Request 1 -> Server A
User Request 2 -> Server B
Suddenly, the session disappears.
Better Approach: Distributed Session Storage
Store sessions in Redis so every server shares the same session storage:
| builder.Services.AddStackExchangeRedisCache(options => { options.Configuration = configuration.GetConnectionString(“Redis”); }); builder.Services.AddSession(); |
- Third-Party APIs Become Your Weakest Link
| var payment = await _paymentGateway.ProcessAsync(request); |
Works fine until the payment provider slows down, the API starts timing out, or the network becomes unstable. Now your entire application waits.
Better Approach: Polly Retry Policy
This automatically retries transient failures using an exponential backoff:
| builder.Services.AddHttpClient<IPaymentService, PaymentService>() .AddTransientHttpErrorPolicy(policy => policy.WaitAndRetryAsync( 3, retry => TimeSpan.FromSeconds(Math.Pow(2, retry)))); |
- Logging Can Become a Bottleneck
Developers often log everything standard string interpolation:
| _logger.LogInformation($”User {userId} opened dashboard”); |
At scale (1 Million users x 10 requests x 5 log entries), you deal with 50 Million logs/day.
Better Approach: Structured Logging
| _logger.LogInformation(“Dashboard opened by User {UserId}”, userId); |
Popular tools: Seq, ELK Stack, Application Insights, Grafana Loki
- File Uploads Will Break Local Storage
| var path = Path.Combine(“uploads”, file.FileName); using var stream = new FileStream(path, FileMode.Create); await file.CopyToAsync(stream); |
This approach may work in a single-server environment, but it becomes difficult to manage as applications scale across multiple instances or deployment environments.
Better Approach: External Blob Storage
| await _blobClient.UploadAsync(file.OpenReadStream(), overwrite: true); |
Options: Azure Blob Storage, AWS S3, Google Cloud Storage
- The Real Bottleneck: Architecture
Many developers ask: Can ASP.NET Core handle one million users? Absolutely. ASP.NET Core and Kestrel are extremely performant.
The bigger question is: Can your architecture handle one million users? Because the first thing that breaks is rarely .NET itself. It’s usually the Database, Session storage, Synchronous processing, External APIs, Memory management, or File storage.
What a Scalable .NET Architecture Looks Like
| Internet ↓ Load Balancer ↓ ASP.NET Core Instances ↓ Redis Cache | Message Queue ↓ Background Workers ↓ SQL Server Cluster | Blob Storage |
Final Thoughts
The biggest mistake developers make is waiting until traffic grows before thinking about scalability. By then, it’s usually too late.
The best time to think about one million users is when you have one hundred.
Because when one million users finally arrive, they won’t expose problems in your code. They’ll expose problems in your architecture. And architecture is much harder to fix under pressure.



