Blocking and deadlocking are often confused, but they are different problems with different causes and different fixes. A deadlock is two sessions waiting on each other. SQL Server detects it, picks a victim, and rolls one of them back. It is noisy and visible in the error logs. Blocking is one session waiting on another that has not finished yet. It is silent. It just accumulates until the application times out or the user gives up.
Most enterprise .NET applications encounter serious blocking not because the developers wrote careless code, but because the database was never under real concurrent load in development. The queries that worked fine with five users become contention bottlenecks with fifty.
Finding it
The starting point is sys.dm_exec_requests and sys.dm_os_waiting_tasks. Query them together and you can see exactly which sessions are blocked, which session is the head blocker, and what query each is running. Adam Machanic's sp_whoisactive is the practical tool most production DBAs reach for. It wraps this information into a readable format and shows the wait type, the blocking chain, and the actual SQL.
Once you have identified a blocking pattern, the next question is whether it is a symptom of a missing index, a long-running transaction, or a concurrency design problem. These have different solutions.
The common causes in .NET applications
The most frequent cause is a transaction that is held open for longer than necessary. EF Core's default behaviour wraps each SaveChanges call in a transaction, which is correct. The problem arises when code opens an explicit transaction, does some processing: fetches an API, validates business rules, waits on I/O. Then it writes. The lock is held for the entire duration, including all the work that has nothing to do with the database.
The fix is not to avoid transactions. It is to minimise what happens inside them. Fetch all the data you need before opening the transaction. Do all the computation. Then open the transaction, write, and commit. The lock window shrinks from seconds to milliseconds.
Missing indexes are the second most common cause. A query doing a table scan under concurrent write load will hold shared locks on every row it reads. Add the right index, the scan becomes a seek, and the lock contention disappears.
What not to do
The first suggestion many teams reach for is READ UNCOMMITTED or NOLOCK hints. This does eliminate blocking, but only by telling the query to read data that other sessions are currently modifying, including rows that will be rolled back. You are trading blocking for dirty reads. In a reporting query, this is sometimes acceptable. In a query that drives a business decision or feeds a write operation, it is not.
A more defensible option is READ_COMMITTED_SNAPSHOT isolation, which gives readers a consistent snapshot of committed data without blocking writers. It has a tempdb overhead cost and needs to be evaluated per workload, but it is a real solution rather than a workaround that introduces data integrity risk.