Idle Blockers in SQL Server: The Silent Killers of Concurrency

Imagine this: a session in SQL Server isn’t running any queries, it’s sitting idle, yet it’s blocking everyone else.

That, my friends, is an idle blocker.

In this post we’ll cover:

  • What an idle blocker actually is
  • How it happens
  • Why it causes so much chaos
  • How to detect it (sp_who2, DBCC INPUTBUFFER, DMVs)
  • Advanced ways to inspect locks and transactions
  • How to kill multiple idle blockers safely
  • And finally — how to prevent them in the first place

🧠 What Is an Idle Blocker?

An idle blocker is a SQL Server session (SPID) that:

  • Isn’t running any active queries
  • Still holds locks on rows, pages, or tables
  • Blocks other sessions from moving forward because it hasn’t released those locks

You’ll usually see it as a session in SLEEPING status that sits at the top of a blocking chain.


🔁 How It Happens

Idle blockers are usually caused by bad client behavior:

  1. Uncommitted Transactions
    A client starts a transaction and never commits or rolls it back. SQL Server holds locks until it knows the outcome.
BEGIN TRANSACTION;
-- Forgot to COMMIT or ROLLBACK
-- Locks are held indefinitely

2. Open Connections Not Closed
In app code (C#, Java, etc.), if you don’t dispose of the connection, the session can linger with locks.

// BAD - missing connection disposal
var conn = new SqlConnection(connString);
conn.Open();
// run a query
// forgot to close or dispose!

3. Lazy Loading in ORMs
Long-lived DbContexts (Entity Framework) or lazy-loaded queries can keep connections open longer than expected.


🚧 Why It’s a Problem

Here’s the chain reaction:

  • SPID 52: starts a transaction, updates a row, doesn’t commit.
  • SPID 60: tries to read the row — gets blocked.
  • SPID 70: waits on SPID 60, and so on.

SPID 52 isn’t doing anything useful, but it’s holding locks. That’s why idle blockers are silent but deadly.


🔎 How to Detect Idle Blockers

Step 1: Find Who’s Blocking Whom

SELECT 
    r.session_id AS BlockedSession,
    r.blocking_session_id AS BlockerSession,
    s.status,
    s.login_name,
    s.host_name,
    s.program_name,
    r.wait_type,
    r.wait_time,
    r.wait_resource
FROM sys.dm_exec_requests r
JOIN sys.dm_exec_sessions s ON r.session_id = s.session_id
WHERE r.blocking_session_id <> 0;

Step 2: Identify the Idle Blocker

SELECT
    s.session_id,
    s.status,
    s.login_name,
    s.host_name,
    s.last_request_start_time,
    s.last_request_end_time,
    r.text AS LastCommand
FROM sys.dm_exec_sessions s
LEFT JOIN sys.dm_exec_requests req 
    ON s.session_id = req.session_id
LEFT JOIN sys.dm_exec_connections c
    ON s.session_id = c.session_id
OUTER APPLY sys.dm_exec_sql_text(c.most_recent_sql_handle) r
WHERE s.session_id IN (
    SELECT DISTINCT blocking_session_id
    FROM sys.dm_exec_requests
    WHERE blocking_session_id != 0
)
AND req.session_id IS NULL; -- only idle blockers

Step 3: Peek Into Its Last Command (Quick Check)

DBCC INPUTBUFFER(<SPID>);

This old-school command gives you a one-line snapshot of what the session last executed.
It’s fast, lightweight, and often enough to tell you whether the blocker is sitting on an open transaction or just waiting.


🔬 A Richer View of Idle Blockers

If you need more detail, use this DMV query:

  • Age of the open transaction (tran_minutes)
  • Log usage (tran_log_mb)
  • Last command text
  • Which sessions are blocked
WITH blocking AS (
    SELECT er.session_id AS victim_session_id,
           er.blocking_session_id AS blocker_session_id
    FROM sys.dm_exec_requests er
    WHERE er.blocking_session_id <> 0
),
idle_blocker_roots AS (
    SELECT DISTINCT b.blocker_session_id AS session_id
    FROM blocking b
    LEFT JOIN sys.dm_exec_requests r
        ON r.session_id = b.blocker_session_id
    WHERE r.session_id IS NULL
),
x AS (
    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,
        s.open_transaction_count,
        c.client_net_address,
        DB_NAME(dt.database_id) AS database_name,
        at.transaction_begin_time,
        DATEDIFF(MINUTE, at.transaction_begin_time, SYSDATETIME()) AS tran_minutes,
        (dt.database_transaction_log_bytes_used/1048576.0) AS tran_log_mb,
        txt.text AS last_command
    FROM idle_blocker_roots r
    JOIN sys.dm_exec_sessions s ON s.session_id = r.session_id
    LEFT JOIN sys.dm_exec_connections c ON c.session_id = s.session_id
    OUTER APPLY sys.dm_exec_sql_text(c.most_recent_sql_handle) txt
    LEFT JOIN sys.dm_tran_session_transactions st ON st.session_id = s.session_id
    LEFT JOIN sys.dm_tran_active_transactions at ON at.transaction_id = st.transaction_id
    LEFT JOIN sys.dm_tran_database_transactions dt ON dt.transaction_id = at.transaction_id
)
SELECT
    x.session_id,
    x.status,
    x.login_name,
    x.host_name,
    x.program_name,
    x.database_name,
    x.open_transaction_count,
    x.transaction_begin_time,
    x.tran_minutes,
    x.tran_log_mb,
    x.client_net_address,
    STUFF((
        SELECT ', ' + CONVERT(varchar(10), b.victim_session_id)
        FROM (SELECT DISTINCT victim_session_id
              FROM blocking
              WHERE blocker_session_id = x.session_id) b
        FOR XML PATH(''), TYPE).value('.', 'nvarchar(max)'), 1, 2, ''
    ) AS blocking_victims,
    x.last_command
FROM x
ORDER BY x.tran_minutes DESC, x.session_id;

Optional: See Locks Held

SELECT
    l.request_session_id AS session_id,
    DB_NAME(l.resource_database_id) AS database_name,
    l.resource_type,
    l.request_mode,
    l.request_status,
    COUNT(*) AS lock_count
FROM sys.dm_tran_locks l
WHERE l.request_session_id IN (
    SELECT DISTINCT er.blocking_session_id
    FROM sys.dm_exec_requests er
    WHERE er.blocking_session_id <> 0
    EXCEPT
    SELECT session_id FROM sys.dm_exec_requests
)
GROUP BY l.request_session_id, l.resource_database_id, l.resource_type, l.request_mode, l.request_status
ORDER BY session_id, database_name;

💣 How to Kill Idle Blockers

The simplest form:

KILL <SPID>;

But this only handles one blocker at a time. If you’ve got several, use a script that finds them all and generates KILL commands.


⚡ Kill Multiple Idle Blockers Safely

Here’s a pattern that:

  • Finds only root idle blockers
  • Excludes your session and critical logins/programs
  • Lets you filter by transaction age
  • Either prints or executes the KILLs
DECLARE @execute BIT = 0;  -- 0 = print, 1 = run
DECLARE @min_tran_age_minutes INT = 5;

DECLARE @ExcludeLogin TABLE (login_name sysname PRIMARY KEY);
INSERT @ExcludeLogin (login_name) VALUES ('sa');

DECLARE @ExcludeProgram TABLE (program_name sysname PRIMARY KEY);
INSERT @ExcludeProgram (program_name) VALUES ('SQLAgent - TSQL JobStep');

WITH blocking AS (
    SELECT er.session_id AS victim_session_id,
           er.blocking_session_id AS blocker_session_id
    FROM sys.dm_exec_requests er
    WHERE er.blocking_session_id <> 0
),
idle_blocker_roots AS (
    SELECT DISTINCT b.blocker_session_id AS session_id
    FROM blocking b
    LEFT JOIN sys.dm_exec_requests r ON r.session_id = b.blocker_session_id
    WHERE r.session_id IS NULL
),
blockers AS (
    SELECT s.session_id,
           s.login_name,
           s.host_name,
           s.program_name,
           at.transaction_begin_time,
           DATEDIFF(MINUTE, at.transaction_begin_time, SYSDATETIME()) AS tran_minutes
    FROM idle_blocker_roots r
    JOIN sys.dm_exec_sessions s ON s.session_id = r.session_id
    LEFT JOIN sys.dm_tran_session_transactions st ON st.session_id = s.session_id
    LEFT JOIN sys.dm_tran_active_transactions at ON at.transaction_id = st.transaction_id
    WHERE s.is_user_process = 1 AND s.session_id <> @@SPID
)
SELECT * INTO #KillList
FROM blockers b
WHERE (b.tran_minutes >= @min_tran_age_minutes OR b.tran_minutes IS NULL)
  AND NOT EXISTS (SELECT 1 FROM @ExcludeLogin x WHERE x.login_name = b.login_name)
  AND NOT EXISTS (SELECT 1 FROM @ExcludeProgram x WHERE x.program_name = b.program_name);

IF NOT EXISTS (SELECT 1 FROM #KillList)
BEGIN
    PRINT 'No idle blockers matched.';
    RETURN;
END

SELECT session_id, login_name, host_name, program_name, tran_minutes
FROM #KillList;

DECLARE @cmd NVARCHAR(MAX) = N'';
SELECT @cmd = @cmd + N'KILL ' + CAST(session_id AS varchar(10)) + ';' + CHAR(13)
FROM #KillList;

IF @execute = 1
    EXEC sys.sp_executesql @cmd;
ELSE
    SELECT KillCommand = 'KILL ' + CAST(session_id AS varchar(10)) + ';'
    FROM #KillList;

🛡️ How to Prevent Idle Blockers

In Application Code

  • Always close connections (using in C#).
  • Set reasonable CommandTimeout and ConnectionTimeout.
  • Always COMMIT or ROLLBACK transactions.
  • Avoid long-lived DbContexts and lazy loading in ORMs.

In SQL Server

  • Turn on READ_COMMITTED_SNAPSHOT to reduce blocking with row versioning.
  • Monitor blocking regularly (alerts/jobs for sessions blocking >30 sec).
  • Log frequent blockers and fix at the source (bad app code, ORM misuse).

🧯 Summary

ProblemCauseFix
Idle BlockerSession holding locks but doing nothingKill safely, fix app code
Open ConnectionApp forgot to closeUse using / dispose properly
Uncommitted TranBEGIN TRAN but no COMMITAlways close transactions
ORM IssuesLazy loading / long-lived contextsUse short-lived contexts

📌 Final Thoughts

Idle blockers are silent performance killers. They don’t show up as “running queries,” so they’re easy to overlook — but they can grind your workload to a halt.

With a few smart queries and disciplined app coding practices, you can:

  • Detect idle blockers in real time
  • Kill them safely (even in bulk)
  • And most importantly, prevent them from happening again.


Discover more from SQLYARD

Subscribe to get the latest posts sent to your email.

Leave a Comment