If you are here from a SER307 warning or a support conversation: this page explains why blocking on an
asynchronous redis call is worse than it looks, why the symptom shows up somewhere else entirely, and what to
do about it.
The short version
Calling .Result, .Wait() or .GetAwaiter().GetResult() on an async method is sync over async. It looks
like a small convenience. What it actually does is hold one thread hostage while waiting for a reply — and
processing that reply also needs a thread. Do this enough times concurrently and the pool runs out of
threads to process replies with, so nothing completes, so nothing releases a thread. The client is not slow;
it is stuck.
// the problem
var value = db.StringGetAsync(key).Result;
// the fix
var value = await db.StringGetAsync(key);
There is no second option. In particular, switching to the synchronous API is not a fix — see below.
Why it fails so badly, rather than just being slow
A multiplexed client makes this worse than the general case, because the thing you are waiting for needs the same resource you are consuming while you wait.
- Your code calls
.Resulton a redis command, and the calling thread blocks. - Redis replies. The bytes arrive at the socket promptly — this part is almost never the problem.
- To turn those bytes into a completed
Task, the client needs a thread. - If every thread is blocked at step 1, step 3 cannot happen.
- Nothing completes, so no thread from step 1 is ever released.
The result is timeouts that look like a network or server problem and are neither. A characteristic sign is a timeout message reporting data already sitting in the socket — the reply is there, it simply cannot be processed.
Why adding threads does not rescue you
The .NET thread-pool creates threads on demand up to its minimum, then throttles hard: beyond that point it adds threads slowly and deliberately, on the order of one or two per second, using a hill-climbing heuristic that is trying to find a throughput optimum. That heuristic assumes threads are working. Here they are blocked, so more threads simply means more blocked threads.
This is why raising MinThreads is not a fix:
- it moves the wall further away rather than removing it, and under load you will arrive at it anyway;
- the pile of blocked threads grows with it, so the failure is bigger when it comes;
- and the underlying call pattern is unchanged.
Raising the minimum can be a reasonable stopgap while you fix the call sites, and it does help a genuine burst of short-lived work. It does not help an application that is systematically blocking on I/O.
Confirming it is this
Look at a timeout message from the client — see Timeouts for how to read one in full. The combination that points here is:
Busyat or aboveMinforWORKERorIOCP, meaning the pool is in its throttled, slow-growth regime;- bytes waiting unread on the connection, meaning the server already answered;
- and timeouts that get worse under load rather than better as caches warm.
A message showing all of it at once looks something like:
Timeout performing GET MyKey (5000ms), inst: 0, qs: 84, in: 487312,
IOCP: (Busy=0,Free=1000,Min=8,Max=1000),
WORKER: (Busy=73,Free=32694,Min=64,Max=32767),
POOL: (Threads=73,QueuedItems=612,CompletedItems=418327,Timers=14)
Read that as: 84 commands are awaiting replies (qs); 476KiB has already arrived and is sitting unread
(in); the worker pool has more busy threads than its minimum, so it is now injecting new ones a couple per
second at most; and 612 work items are queued behind them.
The in figure is the tell. The server has answered and the bytes are in the buffer, so nothing is wrong with
the network, the server, or the connection — there is simply no thread available to pick the reply up.
QueuedItems says the same thing from the other direction: work is arriving faster than the pool can start it.
(POOL is only reported on .NET 5 and above; on .NET Framework it reads n/a.)
If the pool is healthy and you still see timeouts, this is not your problem — look at Timeouts and Thread Theft instead.
Fixing it
In order of preference:
- Make the call path async.
awaitthe call, all the way up. This is the only change that removes the problem rather than accommodating it, and it is worth pushing further up the call stack than feels convenient — a single blocking frame anywhere in the path reintroduces it for everything below. - Take the client off the thread-pool, as a mitigation while you do 1 (see below).
The synchronous API is not an escape hatch
StackExchange.Redis does have a synchronous API, and db.StringGet(key) is not literally sync-over-async:
it does not wrap an asynchronous call and block on the result. That distinction is about mechanism, and it
buys you nothing here, because the consequence is identical.
The calling thread still blocks until redis replies, and processing that reply still needs a thread. If the calling thread came from the thread-pool — as it does under ASP.NET, in hosted services, in timer callbacks, in continuations, in almost anything you did not start yourself — then you are still occupying exactly the resource the reply needs in order to arrive. The pool starves the same way, for the same reason.
So the synchronous API is not a safe way to keep blocking, and this page is not telling you to reach for it. If a caller genuinely cannot be made async, the honest position is that you are trading against the pool and should size and isolate accordingly — not that you have avoided the problem.
Mitigation: give the client its own threads
ConnectionMultiplexer.SetFeatureFlag("DedicatedThreads", true); // early in application startup
This makes the library read and write on threads it owns rather than borrowing the thread-pool, so redis traffic keeps flowing even while the pool is saturated.
Be clear about what this does and does not do. It does not fix the thread-pool — nothing in this library can, because the blocked threads are in your code. What it does is stop redis from being caught in the jam, which usually converts “everything times out” into “the application is slow, and one part of it is obviously blocking”. That is a much better place to debug from, and for many applications it is enough to restore service while the real fix is made. It is not a licence to keep the blocking calls.
Two caveats worth knowing before you enable it:
- it costs a reader and a writer thread for each node you connect to (not RESP2 pub/sub connections, which stay on the thread-pool; RESP3 does not use separate pub/sub connections), so think about it before enabling it against a very wide cluster, where that scales with the number of shards;
- it is deliberately opt-in, and set process-wide at startup rather than per-connection.
Neither is meant to be permanent, and the first one especially. Work is in progress on dedicated readers built
over the platform’s native completion machinery — io_uring on Linux, IOCP on Windows — which would service
many connections from a small fixed set of threads rather than a pair per connection. That is the thing that
would make this practical at any width. There is no date on it, and nothing here depends on it; but if you
have read the caveat above and thought “not with my shard count”, the answer is “not yet” rather than “no”.
Fire-and-forget is a special case
CommandFlags.FireAndForget returns an already-completed task carrying the default value, so blocking on one
does not wait for anything and cannot starve the pool. It is still not doing what it looks like: the value is
fixed before the call returns and is never the server’s answer. Discard it, or drop the fire-and-forget flag
if you actually want a result — see SER306.
The analyzer
The package ships a Roslyn analyzer that flags these at build time:
SER307— blocking on a redis call instead of awaiting it. This page is what it links to.SER306— reading a fire-and-forget result, which is always the default value. SER307 hands that case to this rule, since blocking on an already-completed task is not what starves anything.
See Analyzer rules for the full set, including the transaction rules, which describe a different problem.
Turning SER307 off
It is a warning, so a build with TreatWarningsAsErrors will fail until you act on it or turn it down.
Nobody is going to rewrite a large codebase in an afternoon, and a rule you cannot silence is a rule people
rip out entirely, so:
For a single call site you have decided about — a legacy entry point, an interface you do not control:
#pragma warning disable SER307 // blocking: called from <somewhere that cannot be async>
For a project, while you work through it:
<NoWarn>$(NoWarn);SER307</NoWarn>
Or turn it down rather than off, so it stays visible in the IDE without failing builds — in .editorconfig:
dotnet_diagnostic.SER307.severity = suggestion # or none, silent, warning, error
Worth saying plainly: suppressing it does not make the problem go away, and if you are here because of
timeouts then this rule is pointing at their cause. The #pragma form is the one to prefer where you can,
because it records the decision at the site and keeps the rest of the codebase covered.
See also
- Timeouts — reading a timeout message, and the thread-pool statistics in it
- Thread Theft — a different problem with a similar smell, where the reader thread is hijacked
by continuations rather than starved of threads. Largely historical: the client asks for
RunContinuationsAsynchronouslyby default, so on modern .NET this is handled for you — the mitigation there exists for hosts whose synchronization context cannot be trusted to honour it, which in practice means classic ASP.NET on .NET Framework - Pipelines and multiplexers — why the client is shaped this way in the first place