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

CauseWhat it looks likeFirst thing to check
Blocking cascade pinning workersMany sessions suspended on LCK_M_*, one head blocker often sleeping with an open transactionBlocking chain query below; is the head blocker idle?
External waits holding workersWorkers stuck in preemptive mode on linked server calls, CLR, or extended stored procedures; waits like OLEDB prominentsys.dm_os_waiting_tasks for long preemptive waits
Parallel query explosionMany concurrent parallel queries, worker count far exceeding request countActive requests vs. workers; MAXDOP setting
Application connection leak or retry stormUser connections climbing without matching batch requestssys.dm_exec_sessions grouped by host and program
Memory pressure side effectsOS-level memory pressure (another consumer on the box, oversized max server memory) manifesting as worker stallssys.dm_os_sys_memory system_memory_state_desc
AG thread reservation at scaleLarge AlwaysOn groups reserving workers (AGs reserve 40 threads), compounding other pressuresys.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

  1. 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 need sp_configure 'remote admin connections', 1 with RECONFIGURE, 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.

  2. Confirm exhaustion. Run the scheduler query above. work_queue_count > 0 on VISIBLE ONLINE schedulers, with active workers at or near max_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.

  3. Find what is holding the workers. Run the blocking chain query. The pattern you are looking for: many sessions suspended on LCK_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.

  1. 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_tasks for long waits on OLEDB or PREEMPTIVE_* types. A hung linked server or a dead remote endpoint can pin hundreds of workers.

  2. 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.

  3. Rule out the indirect causes. Check sys.dm_os_sys_memory for 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.

  4. 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

SignalWhy it mattersWarning sign
Active workers vs max_workers_countThe direct exhaustion gauge; works even when wait stats go blindSustained above 60% (watch), 80% (act), 90%+ (imminent)
work_queue_count per schedulerTasks queued with no worker to pick them upAny value > 0 sustained for 60s or more
THREADPOOL wait time and task countConfirmation, but unreliable at full exhaustionRising deltas between samples
Blocking chain depth and head blocker stateThe most common root causeSleeping head blocker, chain depth over 5, blocks over 60s
Batch requests/sec vs transactions/secRequests arriving but nothing completing is the cascade fingerprintStable or rising batches with collapsing transactions
CPU utilizationDifferential: low CPU plus unresponsive points at worker exhaustion, not CPU saturationLow CPU with connection probe failures
User connections vs batch requestsConnection leaks and retry storms show here firstConnections 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_schedulers and sys.dm_os_sys_info on a short interval. Alert on workers above 80% of max and on work_queue_count > 0 sustained.
  • 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_count and 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.