Performance & Resource Use
Performance is a feature, and resources are limited. The goal is not to make everything as fast as possible. It is to be fast enough where it matters, efficient with the resources we pay for, and free of the leaks and unbounded operations that turn a busy day into an outage. Measure before you optimise, and design so load cannot run away.
Most performance problems are not unusual. They are an N+1 query, an unbounded result set, a missing index, a synchronous call that should be async, or a resource that is allocated and never released. The approach is to treat performance as a design choice (especially on hot paths and data access), to measure rather than guess, and to put limits on everything that grows with input or load.
Optimising too early wastes effort and harms clarity. Ignoring performance until production wastes money and causes outages. The balance is judgement: write clear code, know the cost of your data access and external calls, put limits on anything unbounded, and profile with real data when something is actually slow.
Be efficient by design
- DoWatch data-access cost: fetch only the columns and rows you need, page large result sets, index real query patterns, and avoid N+1 round-trips (see Data Modelling & Persistence).
- DoUse async I/O for network, database, and external calls so threads are not blocked waiting, and the service scales under load.
- DoRelease resources promptly (connections, streams, handles) with using/dispose, so nothing leaks under load.
- DoCache on purpose where it helps, with correct keys (including the tenant) and a clear plan for invalidation and expiry. A stale or cross-tenant cache is worse than none.
- ConsiderMoving slow or bursty work (reports, third-party calls, heavy processing) off the request path onto background jobs or queues.
- AlwaysPut limits on anything that grows with input or load: paginate queries, cap batch sizes, and limit result sets, so volume cannot exhaust memory or time.
Measure, and protect against runaway load
- DoMeasure before optimising. Profile with realistic data and traffic, and let evidence, not intuition, point at the real bottleneck.
- DoSet timeouts on every outbound call and apply rate limiting and backpressure, so a spike or a slow dependency degrades gracefully (see Designing for Failure).
- DoTest with production-like data volumes. An operation that is instant on ten rows can be an outage on ten million.
- ConsiderLoad and soak testing for critical paths, and watching for slow memory growth that signals a leak.
- AvoidMicro-optimising clear code on a cold path for imagined gains. Clarity is worth more than nanoseconds nobody will notice.
- NeverRun an unbounded query or load an entire large table into memory on a request path. One big tenant or a growth spike will take the service down.
var customers = conn.Query("SELECT * FROM Customers WHERE TenantId=@t", p);
foreach (var c in customers)
c.Orders = conn.Query("SELECT * FROM Orders WHERE CustomerId=@id", new{c.Id});
One query per customer (N+1), and the whole customer table loaded with no paging. This is fine in dev with 20 rows. For a tenant with 200,000, it starves threads and exhausts memory.
var page = conn.Query(@"SELECT Id, Name FROM Customers
WHERE TenantId=@t ORDER BY Id OFFSET @skip ROWS FETCH NEXT @take ROWS ONLY", p);
// orders fetched in one set-based query keyed by the page's customer ids
Results are paged, columns are explicit, and related data is fetched in a single set-based query. The cost stays flat as the tenant grows.
Self-review checklist
- AskHow does this perform with a large tenant's data, not just my test rows?
- AskIs anything here unbounded (a query, a loop, a load) that should be paged or capped?
- AskAre outbound and DB calls async, with timeouts, and are resources released?
- AskIf I am optimising, do I have a measurement showing this is actually the bottleneck?