SQL Server THREADPOOL waits: worker thread exhaustion and refused connections
Your monitoring says the SQL Server host is fine: CPU at 15%, disk latency normal, memory steady. But the application is timing out, new connections hang, and the instance might as well be down. When you finally get in, the wait stats tell the story: THREADPOOL.
THREADPOOL means every worker thread in the SQLOS pool is busy, and new requests are queuing for a thread that does not exist. From the client’s perspective this is equivalent to connection refusal. The server is not slow. It is not accepting work at all.
The two things to internalize before you touch anything: THREADPOOL is almost always a symptom, not the cause, and the fix is never “just raise max worker threads.” Something is pinning workers. Your job is to find it, usually through the Dedicated Admin Connection, because your normal connection path is part of the casualty list.
What this means
SQL Server does not schedule query work on OS threads directly. It runs its own cooperative schedulers (one per logical CPU) and a finite pool of worker threads. Every active request needs a worker. Parallel queries need several: a query at MAXDOP 8 can consume up to 8 workers at once.
The default pool size on 64-bit systems with 4 to 64 logical CPUs is 512 + ((logical_CPUs - 4) * 16). Most production instances land between 512 and 2048 workers. That sounds like a lot until a blocking chain starts stacking suspended sessions, each one holding a worker while it waits on a lock.
The failure cascade looks like this:
flowchart TD A[Head blocker holds a lock
often an idle uncommitted transaction] --> B[Sessions queue on LCK_M_* waits
each holding a worker thread] B --> C[Active workers climb toward max_workers_count] C --> D[Pool exhausted: work_queue_count > 0] D --> E[New requests wait on THREADPOOL
connections effectively refused] E --> F[App timeouts, retry storms
even more connection attempts] F --> D
Two properties make this nasty. First, the signature is inverted from most outages: CPU drops (workers are suspended, not running), I/O drops (nothing is executing), and throughput collapses while the host looks healthy. Second, the instrumentation degrades exactly when you need it: if no thread is free, the THREADPOOL wait itself may not even be recorded in sys.dm_os_wait_stats. Monitor the active worker count directly, not just the wait type.
One important false positive: brief, short-lived THREADPOOL waits can appear when SQL Server ramps threads back up after an idle period, as the OS allocates new threads. Those resolve in milliseconds and are not exhaustion. Exhaustion is sustained, with work queues on the schedulers and failing connection probes.
Common causes
| Cause | What it looks like | First thing to check |
|---|---|---|
| Blocking cascade pinning workers | Many sessions suspended on LCK_M_*, one head blocker often sleeping with an open transaction | Blocking chain query below; is the head blocker idle? |
| External waits holding workers | Workers stuck in preemptive mode on linked server calls, CLR, or extended stored procedures; waits like OLEDB prominent | sys.dm_os_waiting_tasks for long preemptive waits |
| Parallel query explosion | Many concurrent parallel queries, worker count far exceeding request count | Active requests vs. workers; MAXDOP setting |
| Application connection leak or retry storm | User connections climbing without matching batch requests | sys.dm_exec_sessions grouped by host and program |
| Memory pressure side effects | OS-level memory pressure (another consumer on the box, oversized max server memory) manifesting as worker stalls | sys.dm_os_sys_memory system_memory_state_desc |
| AG thread reservation at scale | Large AlwaysOn groups reserving workers (AGs reserve 40 threads), compounding other pressure | sys.dm_hadr_availability_replica_states, AG database count |
The blocking cascade is the most common by a wide margin. A single sleeping session with an uncommitted transaction can drain a 1000-worker pool in minutes under load.
Quick checks
The catch with all of these: you may not be able to run them on a normal connection. That is what the DAC is for (next section). Run what you can.
-- Instance worker limit
SELECT max_workers_count FROM sys.dm_os_sys_info;
-- Active requests vs the limit
SELECT COUNT(*) AS active_requests
FROM sys.dm_exec_requests
WHERE status IN ('running', 'runnable', 'suspended');
-- Per-scheduler queues: work_queue_count > 0 means active exhaustion
SELECT scheduler_id, current_tasks_count, runnable_tasks_count,
active_workers_count, work_queue_count
FROM sys.dm_os_schedulers
WHERE status = 'VISIBLE ONLINE';
-- Current blocking chains
SELECT
r.session_id AS blocked_session,
r.blocking_session_id AS blocker,
r.wait_type,
r.wait_time / 1000 AS wait_seconds,
DB_NAME(r.database_id) AS database_name,
t.text AS blocked_query_text
FROM sys.dm_exec_requests r
CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) t
WHERE r.blocking_session_id <> 0
ORDER BY r.wait_time DESC;
-- THREADPOOL wait accumulation (may be absent even during exhaustion)
SELECT wait_time_ms, waiting_tasks_count
FROM sys.dm_os_wait_stats
WHERE wait_type = 'THREADPOOL';
# Instance responsiveness probe with a short timeout
sqlcmd -S localhost -Q "SELECT 1" -l 10
A TCP connect that succeeds but a query that hangs is the classic exhaustion signature: the engine is alive, it just has no workers to run your SELECT.
How to diagnose it
Get in through the DAC. The Dedicated Admin Connection reserves one scheduler and bypasses the worker pool. It listens on TCP 1434, local-only by default. From the host:
sqlcmd -S ADMIN:localhost. For remote access you needsp_configure 'remote admin connections', 1withRECONFIGURE, set before the incident. Only one DAC session exists per instance. SQL Server Express does not listen on the DAC port unless started with trace flag 7806.Confirm exhaustion. Run the scheduler query above.
work_queue_count > 0on VISIBLE ONLINE schedulers, with active workers at or nearmax_workers_count, is confirmation. Do not rely on seeing THREADPOOL in wait stats; as noted, the wait may not get recorded when no thread is free.Find what is holding the workers. Run the blocking chain query. The pattern you are looking for: many sessions
suspendedonLCK_M_*, converging on one head blocker. Then check whether the head blocker has an active request or is sleeping:
SELECT
s.session_id, s.status, s.login_name, s.host_name, s.program_name,
s.last_request_start_time, s.last_request_end_time,
t.text AS last_query_text,
(SELECT COUNT(*) FROM sys.dm_exec_requests r2
WHERE r2.blocking_session_id = s.session_id) AS sessions_blocked
FROM sys.dm_exec_sessions s
OUTER APPLY sys.dm_exec_sql_text(s.most_recent_sql_handle) t
WHERE s.session_id IN (
SELECT DISTINCT blocking_session_id FROM sys.dm_exec_requests
WHERE blocking_session_id <> 0
);
A sleeping head blocker with an open transaction is almost always an application bug: a connection returned to the pool with an uncommitted transaction, or an SSMS window someone walked away from.
If blocking is not the cause, look for external waits. Workers stuck calling linked servers, CLR code, or extended stored procedures transition to preemptive mode and can sit there indefinitely if the remote side hangs. Check
sys.dm_os_waiting_tasksfor long waits onOLEDBorPREEMPTIVE_*types. A hung linked server or a dead remote endpoint can pin hundreds of workers.Check parallelism pressure. If active workers are far higher than active requests, parallel queries are multiplying worker consumption. Compare request count to worker count, and review MAXDOP. See SQL Server CXPACKET and CXCONSUMER waits for the parallelism side.
Rule out the indirect causes. Check
sys.dm_os_sys_memoryfor OS memory pressure. Review the error log around the incident for Scheduler Monitor messages: a background watchdog declares a “deadlocked scheduler” condition when workers are stuck in the same state too long, writes a dump, and connections drop. If you see those entries, the stuck workers themselves are the lead.Act on the head blocker. If it is sleeping with an open transaction and you have assessed the rollback cost, kill it:
KILL <session_id>. Rollback can take as long as the original transaction ran. That is still faster than waiting for the pool to drain on its own, because it will not.
Metrics and signals to monitor
| Signal | Why it matters | Warning sign |
|---|---|---|
Active workers vs max_workers_count | The direct exhaustion gauge; works even when wait stats go blind | Sustained above 60% (watch), 80% (act), 90%+ (imminent) |
work_queue_count per scheduler | Tasks queued with no worker to pick them up | Any value > 0 sustained for 60s or more |
| THREADPOOL wait time and task count | Confirmation, but unreliable at full exhaustion | Rising deltas between samples |
| Blocking chain depth and head blocker state | The most common root cause | Sleeping head blocker, chain depth over 5, blocks over 60s |
| Batch requests/sec vs transactions/sec | Requests arriving but nothing completing is the cascade fingerprint | Stable or rising batches with collapsing transactions |
| CPU utilization | Differential: low CPU plus unresponsive points at worker exhaustion, not CPU saturation | Low CPU with connection probe failures |
| User connections vs batch requests | Connection leaks and retry storms show here first | Connections climbing without matching request rate |
For the CPU differential in more depth, see SQL Server CPU utilization high and SQL Server runnable tasks backlog for the in-engine CPU queue that looks similar from the outside but has the opposite signature (high CPU, runnable_tasks_count elevated, work_queue_count zero).
Fixes
Kill the head blocker
If a sleeping session holds locks with an open transaction, KILL <session_id> after assessing what it was doing. Expect rollback time. This resolves the immediate cascade: blocked sessions acquire their locks, complete, and release workers.
Tradeoff: you are discarding someone’s transaction. If the head blocker is actively running (not sleeping), it may be a legitimate long batch operation; killing it means a long rollback and lost work. Exhaust alternatives first if the chain is shallow.
Fix the application transaction handling
The permanent fix for the most common cause. Connections returned to the pool with uncommitted transactions are the classic source. Audit application code for missing commits, especially around error handling paths where a failed statement skips the commit. This is where the incident review should land.
Constrain parallelism
If parallel queries are consuming the pool, review MAXDOP and cost threshold for parallelism. Too many concurrent parallel queries at high DOP multiply worker consumption without multiplying throughput proportionally.
Address external dependencies
Hung linked servers and CLR calls that never return need their own fixes: timeouts on linked server queries, removing or isolating CLR code, killing sessions stuck in preemptive waits when the remote side is confirmed dead.
Raising max worker threads: last resort, mostly wrong
Yes, max worker threads is configurable. Raising it without fixing the consumer buys minutes, not safety. Each additional worker costs memory (roughly 2 MB of stack per thread), and a larger pool means a bigger pile of suspended workers when the next blocking event happens. The playbook guidance stands: find what consumes workers, do not just add more. If you do raise it as a bridge measure, treat it as temporary and track the root cause fix separately.
Prevention
- Enable remote DAC before you need it.
sp_configure 'remote admin connections', 1. During the incident is the wrong time to discover your only path in requires console access to the host. - Monitor active workers, not just the wait type. Sample
sys.dm_os_schedulersandsys.dm_os_sys_infoon a short interval. Alert on workers above 80% of max and onwork_queue_count > 0sustained. - Alert on blocking, early. A head blocker detected at 30 seconds and chain depth 3 is a ticket. At 5 minutes and depth 20 it is a THREADPOOL page. Catch it at the first stage.
- Baseline batch requests/sec and transactions/sec. The divergence between them is your earliest cascade indicator. See SQL Server high compilations per second for another workload-shape baseline worth having.
- Fix transaction hygiene in the application tier. Most THREADPOOL incidents trace back to a sleeping uncommitted transaction. Connection pool configuration and error-path commits are prevention, not cleanup.
- Keep DMV history off the box. DMVs reset on restart and degrade under exhaustion. External persistence of worker counts, blocking snapshots, and wait deltas is what lets you reconstruct the incident.
How Netdata helps
- Netdata collects SQL Server wait statistics as a time series, so THREADPOOL deltas are visible per second instead of being reconstructed from cumulative DMV snapshots after the fact.
- Worker thread utilization against
max_workers_countand per-scheduler queue depth are charted continuously, catching the climb toward exhaustion before connections start failing. - Correlating batch requests/sec against transactions/sec on the same dashboard makes the “arriving but not completing” cascade signature obvious in one view.
- Blocking chain visibility alongside lock wait breakdowns lets you move from “the instance is refusing work” to “session 87 is the head blocker” without assembling DMVs by hand mid-incident.
- The CPU-versus-responsiveness divergence that defines this failure mode is visible immediately when OS metrics and SQL Server metrics share one timeline.
Netdata’s Microsoft SQL Server monitoring with Netdata brings these signals together with per-second metrics and ML anomaly detection.
Related guides
- SQL Server CPU utilization high: telling query load apart from a bad plan
- SQL Server CXPACKET and CXCONSUMER waits: parallelism, MAXDOP, and what is actually wrong
- SQL Server high compilations per second: plan cache pollution and CPU burn
- SQL Server runnable tasks backlog: the in-engine CPU queue OS metrics miss






