System Design
Why Scaling Matters Even Before Your Application Gets Big
Scaling isn't about traffic you don't have yet. A few .NET habits, keeping the app stateless, async all the way down, and not calling the database in a loop, cost nothing early and are painful to add later.
"You don't need to worry about scaling yet" is advice I've heard a lot, and it's mostly right. Designing for millions of users when you have none is how projects never ship.
But there's a category of decision that costs nothing when you make it early and is genuinely painful to retrofit later, and as a junior developer those are the ones worth knowing about. Not because my projects have traffic, but because some of these are the difference between adding a second server being a config change or a rewrite.
Keep state out of the application
This is the big one. If my API stores anything important in its own memory, then a request has to come back to the same instance to work correctly, and that means I can never run two copies.
Session data in memory, a cached list in a static field, an uploaded file written to local disk. Each of those quietly ties a user to one specific instance. Move them out, into a database, a distributed cache, blob storage, and suddenly it doesn't matter which instance answers a request.
I ran into this concretely with the URL shortener I built. Three copies of the app behind a load balancer only works because none of them keep anything locally. Every copy sees the same data because the data lives in Cassandra and Redis, not in the app. That wasn't a scaling optimisation, it was just how the app had to be written for load balancing to be possible at all.
Async all the way down
In ASP.NET Core, a thread blocked waiting on a database call is a thread that can't serve another request. With one user that's invisible. With a hundred concurrent requests it's the difference between handling them and queueing them.
// blocks a thread while waiting
var product = _context.Products.Find(id);
// releases the thread back to the pool
var product = await _context.Products.FindAsync(id);
The async version isn't faster for a single request. It just means the thread goes back to the pool instead of sitting there doing nothing, which is what lets a small server handle far more concurrent work than it has threads.
Writing await from the start costs nothing. Converting a synchronous codebase to async later means touching every method in the call chain, because async has to go all the way down.
Don't call the database in a loop
The classic mistake, and one I've made:
var orders = await _context.Orders.ToListAsync();
foreach (var order in orders)
{
// one extra query per order
order.Customer = await _context.Customers.FindAsync(order.CustomerId);
}
With ten orders that's eleven queries and nobody notices. With a thousand orders it's a thousand and one, and the endpoint times out. The fix is asking for what you need up front:
var orders = await _context.Orders
.Include(o => o.Customer)
.ToListAsync();
What makes this worth catching early is that it works fine in development. My test database has five rows in it. The problem only appears with real data, which is the worst time to find it.
Only fetch what you need
Related habit: AsNoTracking() on read-only queries, and selecting the specific columns rather than whole entities when I only need a couple of fields.
var names = await _context.Products
.AsNoTracking()
.Select(p => new { p.Id, p.Name })
.ToListAsync();
EF Core's change tracker exists so it can detect what you modified and generate updates. On a query where nothing gets modified, that's bookkeeping for no reason.
Put the indexes in early
If I know a column is going to be queried, the index belongs in the migration that creates the table. Adding an index to an empty table is instant. Adding one to a large table in production is an operation you have to plan around.
What I'd still ignore
Caching layers, message queues, read replicas, sharding, any of that. Those solve problems I can measure, and I can't measure them yet. Adding a cache to a project with no traffic just means more code and a new class of bug where the cache and the database disagree.
The actual line I draw
The question I ask isn't "will this scale." It's "if this needs to change later, is it a config change or a rewrite?"
Stateless, async, no queries in loops, sensible indexes — those are all "write it this way from the start and it's free." Caching, queues, and replicas are all "add it when you have a measurement that says you need it." Getting the first list right early is what makes the second list a choice instead of an emergency.