Why Waits Come First
The time a query takes divides cleanly into two parts: time spent doing work, and time spent waiting to be allowed to do work. On a server that feels slow, the second part is almost always the larger one — and unlike the first, SQL Server records it in detail and hands it to you for free.
That is what makes wait-based analysis the sensible place to start. It does not ask you to guess which counter matters, or to reason backwards from CPU utilisation to a cause. It asks the engine a direct question — what were you waiting for, how long, and how often — and the answer narrows a whole server down to one or two things worth investigating, usually within a couple of minutes.
What it does not do is hand you a fix. A wait type is a direction. Everything after the first query on this page is about turning that direction into something specific enough to change.
The Three Task States
Every task inside SQL Server is, at any instant, in exactly one of three states. Understanding the cycle between them is what makes the columns in the DMV mean something.
Executing on a CPU scheduler right now. Only one task per scheduler is in this state at a time.
Has everything it needs and is queued for a scheduler. Time spent here is signal wait — pure CPU scheduling delay.
Waiting for something it does not have — a page, a lock, a memory grant. Time spent here is resource wait.
The cycle runs suspended → runnable → running, and a task goes round it constantly. Because a single wait usually passes through both waiting states, the recorded wait_time_ms contains both parts, and signal_wait_time_ms is the second one broken out. Subtract to get the resource half. That subtraction is the single most useful thing you can do with the raw numbers: it separates the resource is slow from the CPU is busy before you have looked at a single wait type.
Where the Data Lives
Five DMVs matter, and they answer genuinely different questions. Most bad wait analysis comes from asking the first one a question only the last three can answer.
Every wait the instance has recorded since the last service restart, failover, or explicit clear. No time dimension and no attribution — this is the starting point, never the finishing one.
What is suspended at this exact moment, including the resource description and the blocking session. This is where a lock wait stops being a number and becomes a session id.
Every executing request with its current wait type, accumulated wait time, and blocking session. Joins to the SQL text, which makes it the most practical live view.
The same shape as the instance DMV but per session, added in SQL Server 2016. The rows disappear when the session does, so capture while it is still connected.
Waits attributed to a specific query and plan, retained across restarts, added in SQL Server 2017. Waits are rolled up into about two dozen categories rather than individual types — less precise, but the only source that survives a reboot.
The Query to Start With
This ranks wait types by share of total wait time, filters out the background waits that would otherwise dominate, and — importantly — returns the average wait alongside the total, plus a running percentage so you can see where the list stops mattering.
WITH waits AS (
SELECT
wait_type,
waiting_tasks_count AS wait_count,
wait_time_ms / 1000.0 AS wait_s,
(wait_time_ms - signal_wait_time_ms) / 1000.0 AS resource_s,
signal_wait_time_ms / 1000.0 AS signal_s,
max_wait_time_ms,
100.0 * wait_time_ms
/ NULLIF(SUM(wait_time_ms) OVER (), 0) AS pct
FROM sys.dm_os_wait_stats
WHERE waiting_tasks_count > 0
AND wait_type NOT IN (
-- Background and timer waits. These are the server doing nothing,
-- on purpose, and on an instance with real uptime they will
-- otherwise occupy the entire top of this list.
N'BROKER_EVENTHANDLER', N'BROKER_RECEIVE_WAITFOR',
N'BROKER_TASK_STOP', N'BROKER_TO_FLUSH',
N'BROKER_TRANSMITTER', N'CHECKPOINT_QUEUE',
N'CHKPT', N'CLR_AUTO_EVENT',
N'CLR_MANUAL_EVENT', N'CLR_SEMAPHORE',
N'DBMIRROR_DBM_EVENT', N'DBMIRROR_EVENTS_QUEUE',
N'DBMIRROR_WORKER_QUEUE', N'DBMIRRORING_CMD',
N'DIRTY_PAGE_POLL', N'DISPATCHER_QUEUE_SEMAPHORE',
N'FT_IFTS_SCHEDULER_IDLE_WAIT', N'FT_IFTSHC_MUTEX',
N'HADR_CLUSAPI_CALL', N'HADR_FILESTREAM_IOMGR_IOCOMPLETION',
N'HADR_LOGCAPTURE_WAIT', N'HADR_NOTIFICATION_DEQUEUE',
N'HADR_TIMER_TASK', N'HADR_WORK_QUEUE',
N'LAZYWRITER_SLEEP', N'LOGMGR_QUEUE',
N'ONDEMAND_TASK_QUEUE', N'PWAIT_ALL_COMPONENTS_INITIALIZED',
N'QDS_ASYNC_QUEUE', N'QDS_CLEANUP_STALE_QUERIES_TASK_MAIN_LOOP_SLEEP',
N'QDS_PERSIST_TASK_MAIN_LOOP_SLEEP', N'QDS_SHUTDOWN_QUEUE',
N'REQUEST_FOR_DEADLOCK_SEARCH', N'SLEEP_BPOOL_FLUSH',
N'SLEEP_DBSTARTUP', N'SLEEP_DCOMSTARTUP',
N'SLEEP_SYSTEMTASK', N'SLEEP_TASK',
N'SP_SERVER_DIAGNOSTICS_SLEEP', N'SQLTRACE_BUFFER_FLUSH',
N'SQLTRACE_INCREMENTAL_FLUSH_SLEEP', N'SQLTRACE_WAIT_ENTRIES',
N'WAIT_XTP_HOST_WAIT', N'WAITFOR',
N'XE_DISPATCHER_WAIT', N'XE_TIMER_EVENT',
-- Real work, but not a problem indicator on its own.
N'CXCONSUMER'
)
)
SELECT
wait_type,
CAST(wait_s AS decimal(16, 2)) AS wait_s,
CAST(resource_s AS decimal(16, 2)) AS resource_s,
CAST(signal_s AS decimal(16, 2)) AS signal_s,
wait_count,
CAST(wait_s * 1000.0 / wait_count AS decimal(16, 2)) AS avg_wait_ms,
max_wait_time_ms,
CAST(pct AS decimal(5, 2)) AS pct,
CAST(SUM(pct) OVER (ORDER BY pct DESC
ROWS UNBOUNDED PRECEDING)
AS decimal(5, 2)) AS running_pct
FROM waits
ORDER BY pct DESC;In practice the first four or five rows account for nearly all the time. When running_pct passes about ninety-five, stop reading — what is below that line is noise dressed up as data.
Run this second, to get the instance-wide split between scheduling delay and real resource waiting:
SELECT
CAST(100.0 * SUM(signal_wait_time_ms)
/ NULLIF(SUM(wait_time_ms), 0) AS decimal(5, 2)) AS signal_pct,
CAST(100.0 * SUM(wait_time_ms - signal_wait_time_ms)
/ NULLIF(SUM(wait_time_ms), 0) AS decimal(5, 2)) AS resource_pct
FROM sys.dm_os_wait_stats
WHERE waiting_tasks_count > 0
AND wait_type NOT IN (N'SLEEP_TASK', N'LAZYWRITER_SLEEP', N'WAITFOR',
N'XE_TIMER_EVENT', N'REQUEST_FOR_DEADLOCK_SEARCH',
N'DIRTY_PAGE_POLL', N'SP_SERVER_DIAGNOSTICS_SLEEP',
N'HADR_WORK_QUEUE', N'QDS_ASYNC_QUEUE');
-- Use the same ignore list as the query above. A signal ratio computed over
-- sleeping background tasks describes the background tasks, not your workload.A signal share above roughly a fifth to a quarter is worth treating as CPU pressure in its own right. When it is that high, the specific wait types below it are largely downstream of the scheduling problem, and chasing them individually will not get you anywhere.
Reading the Output
Here is a shortened result from a test instance. The ranking and the averages disagree, which is the whole point of the example.
| Wait type | Wait (s) | Avg (ms) | % | Reads as |
|---|---|---|---|---|
| CXPACKET | 4,182.55 | 0.41 | 38.10 | Volume, not latency |
| PAGEIOLATCH_SH | 2,904.11 | 21.87 | 26.45 | Slow per read |
| SOS_SCHEDULER_YIELD | 1,733.02 | 0.03 | 15.78 | CPU-bound work |
| LCK_M_X | 1,120.40 | 486.29 | 10.20 | Few waits, very long |
| WRITELOG | 644.87 | 1.94 | 5.87 | Healthy log latency |
CXPACKET is top of the list and is the least interesting row on it: four thousand seconds accumulated in fractions of a millisecond at a time, which is what parallelism looks like when it is working. PAGEIOLATCH_SH is second by total but first by consequence — nearly 22 ms per read is storage that cannot keep up, or a workload asking it for far too much.
LCK_M_X is fourth at ten percent, and it is the row that would make users complain: an average of 486 ms means individual statements are stalling for half a second behind someone else's transaction. A ranking by total alone would have you working on the parallelism first and the blocking last, in exactly the wrong order.
Taking a Delta
Everything above still describes the whole life of the instance. To isolate a window — an incident, a batch run, the fifteen minutes before and after a change — capture the counters twice and subtract.
-- Step 1. Capture the starting point. DROP TABLE IF EXISTS #wait_before; SELECT wait_type, waiting_tasks_count, wait_time_ms, signal_wait_time_ms INTO #wait_before FROM sys.dm_os_wait_stats;
Then let the window pass. Do not use WAITFOR DELAY in the same session to wait it out: that blocks the session you need, and the session's own wait shows up in the results. Just come back to the same query window when the time is up.
-- Step 2. Let the window you care about pass, then subtract.
-- Run this in the SAME session, so that #wait_before is still there.
SELECT TOP (20)
a.wait_type,
a.waiting_tasks_count - ISNULL(b.waiting_tasks_count, 0) AS wait_count,
CAST((a.wait_time_ms - ISNULL(b.wait_time_ms, 0)) / 1000.0
AS decimal(16, 2)) AS wait_s,
CAST(((a.wait_time_ms - ISNULL(b.wait_time_ms, 0))
- (a.signal_wait_time_ms - ISNULL(b.signal_wait_time_ms, 0))) / 1000.0
AS decimal(16, 2)) AS resource_s,
CAST(1.0 * (a.wait_time_ms - ISNULL(b.wait_time_ms, 0))
/ NULLIF(a.waiting_tasks_count - ISNULL(b.waiting_tasks_count, 0), 0)
AS decimal(16, 2)) AS avg_wait_ms
FROM sys.dm_os_wait_stats AS a
LEFT JOIN #wait_before AS b ON b.wait_type = a.wait_type
WHERE a.wait_time_ms - ISNULL(b.wait_time_ms, 0) > 0
ORDER BY wait_s DESC;
-- If any row comes back negative, the instance restarted or someone cleared the
-- counters between the two captures. Throw the result away and start again.For anything longer-lived than a single investigation, write the same capture into a permanent table on a schedule instead of a temp table. Two weeks of fifteen-minute snapshots is small, cheap, and turns every future question about “is this normal?” into a query rather than an argument.
What Is Waiting Right Now
Cumulative counters cannot tell you who. For that you need the live view, which returns the waiting sessions, what they are waiting on, and — for lock waits — the session blocking them.
SELECT
r.session_id,
r.blocking_session_id,
r.wait_type,
r.wait_time AS wait_ms,
r.last_wait_type,
r.wait_resource,
DB_NAME(r.database_id) AS database_name,
r.status,
r.command,
s.login_name,
s.host_name,
s.program_name,
SUBSTRING(t.text,
(r.statement_start_offset / 2) + 1,
((CASE r.statement_end_offset
WHEN -1 THEN DATALENGTH(t.text)
ELSE r.statement_end_offset
END - r.statement_start_offset) / 2) + 1) AS running_statement
FROM sys.dm_exec_requests AS r
JOIN sys.dm_exec_sessions AS s
ON s.session_id = r.session_id
OUTER APPLY sys.dm_exec_sql_text(r.sql_handle) AS t
WHERE r.session_id <> @@SPID
AND s.is_user_process = 1
ORDER BY r.wait_time DESC;Two columns repay attention. blocking_session_id turns an abstract LCK_M_X total into a specific session you can go and look at, and where several rows point at the same id you have found the head of a blocking chain. wait_resource names the thing being waited on — for tempdb allocation contention it reads as 2:1:1 or 2:1:3, which identifies the problem outright.
Attributing Waits to a Query
A server-level wait tells you the instance spent time on something. It does not tell you which query did it, and that is the step where wait analysis either becomes actionable or stalls. Query Store closes the gap and, unlike the live DMVs, its history survives a restart.
SELECT TOP (20)
ws.wait_category_desc,
q.query_id,
p.plan_id,
CAST(SUM(ws.total_query_wait_time_ms) / 1000.0
AS decimal(16, 2)) AS wait_s,
MIN(qt.query_sql_text) AS query_text
FROM sys.query_store_wait_stats AS ws
JOIN sys.query_store_plan AS p ON p.plan_id = ws.plan_id
JOIN sys.query_store_query AS q ON q.query_id = p.query_id
JOIN sys.query_store_query_text AS qt ON qt.query_text_id = q.query_text_id
JOIN sys.query_store_runtime_stats_interval AS i
ON i.runtime_stats_interval_id = ws.runtime_stats_interval_id
WHERE i.start_time >= DATEADD(hour, -24, SYSUTCDATETIME())
GROUP BY ws.wait_category_desc, q.query_id, p.plan_id
ORDER BY wait_s DESC;
-- Requires Query Store to be ON for the database, and SQL Server 2017 or later.
-- Waits arrive pre-grouped into categories such as Lock, Buffer IO and CPU, so
-- you get the query but not the exact wait type. That is usually the trade you
-- want: the query is the part you can change.For a session you are watching right now, sys.dm_exec_session_wait_stats gives the same shape as the instance DMV scoped to one session_id. It is the cleanest way to profile a single batch: read it before the batch starts, read it again when it finishes, and subtract. The rows vanish when the session disconnects, so capture before you close the window.
Wait-Type Playbooks
Nine waits that account for most of what you will actually meet. Each one lists the likely causes in rough order of frequency, what confirms the hypothesis, and the fixes ordered cheapest first — because the expensive fix is rarely the one that was needed.
PAGEIOLATCH_SH
Reading data pages from disk because they are not in the buffer pool.
- The workload reads far more pages than it needs — a missing index, a non-SARGable predicate, or an implicit conversion that turned a seek into a scan.
- The buffer pool is too small to hold the working set, so the same pages are read repeatedly.
- Storage is genuinely slow.
- A one-off scan — a report, a rebuild, an integrity check — inflating a cumulative total that no longer reflects normal operation.
- Index so the query seeks instead of scanning, and cover it if the lookup count is the real cost.
- Remove the implicit conversion or wrapped column that prevents the seek.
- Return fewer columns and fewer rows.
- Add memory.
- Move the data files to faster storage — last, because it is the most expensive fix for a problem the first four usually solve.
Confirm with: sys.dm_io_virtual_file_stats for per-file read latency, page life expectancy over time, and the top queries by physical reads.
LCK_M_* (blocking)
A task is waiting to acquire a lock. The suffix names the mode; all of them mean blocking.
- Transactions held open longer than they need to be, often by an application that starts one and then does work outside the database.
- A missing index forcing a scan, so the writer locks far more rows than it modifies.
- Lock escalation turning row locks into a table lock.
- Reporting queries under the default read committed isolation taking shared locks against an OLTP workload.
- Shorten the transaction. This is almost always the real fix.
- Index so the writer touches fewer rows.
- Consider read committed snapshot isolation, understanding that it moves the cost into the tempdb version store rather than removing it.
- Do not reach for NOLOCK. It does not fix blocking; it trades correctness for it, and it can return rows twice or skip them entirely.
Confirm with: blocking_session_id in sys.dm_exec_requests while it is happening, sys.dm_os_waiting_tasks for the resource, and the blocked process report for anything that outlives your attention span.
CXPACKET
Threads waiting at an exchange operator in a parallel plan.
- Cost threshold for parallelism still at its default of 5, sending trivial queries parallel.
- MAXDOP left at 0 on a machine with a high core count.
- Skewed row distribution — one thread doing most of the work while the rest wait — usually from stale or insufficient statistics.
- A plan that should not be parallel at all because the underlying query is doing too much.
- Raise cost threshold for parallelism to something appropriate for the hardware, then measure. Values in the twenties to fifties are common starting points, but this is a setting to tune, not to copy.
- Set MAXDOP according to the core and NUMA layout rather than leaving it at 0.
- Update statistics and fix the cardinality estimate causing the skew.
- Do not set MAXDOP to 1 to make the wait type disappear. It will, and the workload will get slower.
Confirm with: The actual execution plan, looking at rows per thread on the exchange, and whether the same queries appear at the top of the CPU list.
SOS_SCHEDULER_YIELD
A task used its full 4 ms quantum and yielded voluntarily. Expect a huge count and a sub-millisecond average.
- A CPU-intensive query working through pages that are already in memory — usually a scan that should have been a seek.
- Not enough cores for the concurrency the workload demands.
- Spinlock contention, which is real but rare enough that it should be the last hypothesis rather than the first.
- Fix the query and the plan doing the scanning. This is nearly always where the time is.
- Only after that, consider whether the server has enough cores.
Confirm with: runnable_tasks_count in sys.dm_os_schedulers, the instance-wide signal wait percentage, and the top queries by worker time.
WRITELOG
A commit waiting for its log block to be hardened to the transaction log.
- Log file storage latency.
- Transaction shape — thousands of single-statement autocommit transactions each forcing their own log flush.
- A synchronous-commit availability group, where the wait is really HADR_SYNC_COMMIT wearing a different hat.
- Batch the writes into explicit transactions instead of committing per row.
- Put the log on the lowest-latency storage available; the log is a sequential write path and benefits disproportionately.
- Check for virtual log file sprawl from repeated small autogrowths.
- Delayed durability only as a deliberate, documented decision — it trades a window of committed-but-lost transactions for throughput.
Confirm with: sys.dm_io_virtual_file_stats against the log file, and transactions per second from the performance counters.
RESOURCE_SEMAPHORE
A query waiting for a memory grant before it can start running.
- Overestimated grants from bad cardinality estimates. This is the common case, and it is an estimation problem rather than a memory problem.
- A small number of very large grants starving everything else.
- max server memory set too low for the workload.
- A Resource Governor pool capping the grant.
- Fix the estimate: update statistics, rewrite the predicate, and remember that table variables gave the optimiser no row estimate to work with before SQL Server 2019.
- Constrain the outliers with MIN_GRANT_PERCENT and MAX_GRANT_PERCENT hints.
- Let memory grant feedback do it, on the versions that support it.
- Add RAM last, once you know the grants are honest.
Confirm with: sys.dm_exec_query_memory_grants while it is happening, and granted versus used grant size in sys.dm_exec_query_stats afterwards.
ASYNC_NETWORK_IO
Results are ready and SQL Server is waiting for the client to take them.
- An application reading the result set row by row while doing work between rows, holding it open the whole time.
- Returning far more data than the application actually uses.
- A genuinely slow link between client and server.
- Someone running a large query in a client that renders every row into a grid.
- Consume the result set completely, then process it. This single change removes most of this wait.
- Paginate, and select only the columns and rows that are used.
- Investigate the network only after the first two have been ruled out.
Confirm with: program_name and host_name on the waiting sessions. If it is always the same application, it is that application.
PAGELATCH_UP on tempdb
Contention on allocation bitmap pages — PFS, GAM and SGAM. In memory, not on disk.
- Many sessions creating and dropping temporary objects concurrently, all hitting the same allocation pages.
- Too few tempdb data files for the concurrency.
- Multiple equally sized tempdb data files with identical autogrowth. SQL Server 2016 and later configure this at setup.
- Reduce temporary object churn in the workload itself.
- On SQL Server 2019 and later, memory-optimized tempdb metadata addresses the related contention on tempdb system tables.
Confirm with: wait_resource in sys.dm_os_waiting_tasks reading as 2:1:1, 2:1:2 or 2:1:3 — database 2, file 1, and the allocation page number.
THREADPOOL
A task could not start because no worker thread was available. Treat this as an incident, not a tuning opportunity.
- A blocking chain holding workers hostage — each blocked session keeps its worker parked for as long as it waits.
- Concurrency far beyond what the instance was sized for.
- Runaway parallelism consuming many workers per query.
- Find and clear the head blocker, then fix whatever produced the blocking.
- Raising max worker threads treats the symptom and can make the server less stable. It is not the fix.
Confirm with: work_queue_count in sys.dm_os_schedulers. If you cannot connect at all, this is what the dedicated administrator connection exists for.
Six Common Mistakes
On a server up for months, the top of sys.dm_os_wait_stats is a months-long average that includes every rebuild, backup and month-end batch. It cannot describe an incident that started an hour ago, no matter how carefully you read it.
The background tasks that sleep on timers accumulate enormous totals while doing nothing. Unfiltered, they will occupy the entire top of the list and push everything real off the bottom.
Sixty percent of total wait across forty million waits is a volume problem. Sixty percent across three hundred waits is a latency problem. Same percentage, different investigations, different fixes.
MAXDOP 1 removes CXPACKET. NOLOCK removes lock waits. Both make the number go away without making anything faster, and the second one changes what your queries return.
DBCC SQLPERF with CLEAR is server-wide and irreversible, and it destroys the history of every other tool reading the same DMV. Taking a delta gets you the same isolated window with none of that.
Two simultaneous changes produce one uninterpretable result. Change one thing, re-measure over the same window with the same filters, and only then move on.
Version and Platform Differences
Wait statistics have been stable for a long time, but several changes affect how you read them and which fixes are available to you.
Everything in this guide runs here. The window-function syntax in the top-waits query needs 2012 as a minimum.
sys.dm_exec_session_wait_stats arrives, giving per-session totals. Setup now configures multiple tempdb data files and uniform extent allocation by default, which removes the most common source of tempdb PAGELATCH contention on new builds.
CXCONSUMER is split out of CXPACKET. After this change, CXPACKET is a more meaningful signal because the benign consumer-side waiting has been moved out of it.
sys.query_store_wait_stats arrives — the first source that attributes waits to a query and plan and survives a restart. Batch-mode memory grant feedback starts correcting the estimates behind RESOURCE_SEMAPHORE.
OPTIMIZE_FOR_SEQUENTIAL_KEY addresses last-page insert contention directly. Memory grant feedback extends to row mode, and memory-optimized tempdb metadata removes contention on tempdb system tables.
Memory grant feedback becomes persistent through Query Store, so corrections survive a restart, and degree-of-parallelism feedback starts adjusting MAXDOP per query.
Use sys.dm_db_wait_stats, which is scoped to the database rather than the instance and needs VIEW DATABASE STATE. The counters reset on failover and on a service objective change, so a since-restart total is even less durable than on-premises. Managed Instance behaves like a normal instance and exposes sys.dm_os_wait_stats.
Frequently Asked Questions
What permissions do I need to read wait statistics?
VIEW SERVER STATE on the instance, or VIEW DATABASE STATE for Azure SQL Database. Everything in this guide is read-only — the DMVs report state and modifying anything is not possible through them. The one exception is the DBCC SQLPERF clear command, which is destructive and needs sysadmin.
How often should I sample?
It depends on the question. During a live incident, deltas of thirty to sixty seconds show you what is happening now. For a baseline, capture every fifteen to thirty minutes and keep several weeks, so that you can compare a bad Tuesday morning against a normal one rather than against an all-time average. Sampling itself is cheap — reading the DMV is a memory read, not a scan.
Does querying wait statistics affect the results?
Negligibly. Your own session does accumulate waits like any other, which is why the live query above excludes @@SPID, but the collection cost is not something you will see in the numbers.
Can I get wait statistics for a single database?
Not from sys.dm_os_wait_stats — it is instance-wide and has no database column at all. Query Store wait statistics are per database by definition, and the live DMVs carry database_id, so both of those can answer the question. The cumulative instance view cannot.
Do wait statistics work on Azure SQL Database?
Yes, through sys.dm_db_wait_stats, scoped to your database. The important difference is that the counters reset whenever the database fails over or changes service objective, which happens far more often than an on-premises restart. Delta capture matters more there, not less.
How do I build a baseline?
Snapshot the DMV into a permanent table on a schedule, keep at least two weeks, and compare like periods against each other. A baseline is not a single "good" number to measure against — it is a normal shape, and what you are looking for is a departure from it.
Related Reading
Wait analysis is one step inside a larger method; how to diagnose SQL Server performance problems covers the steps on either side of it — scoping the symptom first, and narrowing a dominant wait down to a single query afterwards.
The Wait Statistics module in SQL Performance Intelligence™ runs the collection, delta and attribution steps described here read-only against a live instance, and keeps a wait-type reference alongside the results. Blocking Analysis is the path to take when lock waits dominate, and Query Statistics covers the plan-level evidence once a wait has been traced back to a query.