Skip to content
WindowsDeep Dive Published Updated 10 min readViews unavailable

Diagnose GDI and USER Handle Leaks in Windows Desktop Applications

Measure per-process GDI and USER object trends, identify leaking creation paths, and fix ownership errors without raising quotas as a first response.

A Windows desktop application can run out of graphical resources while its private bytes and kernel-handle count look normal. GDI objects and USER objects are separate per-process resource classes used by painting, menus, windows, device contexts, fonts, brushes, bitmaps, and other UI work. When a program creates them without releasing ownership, operations can begin failing far away from the allocation site: a window stops repainting, a font selection fails, a menu is incomplete, or a drawing API returns an unexpected error.

Diagnose the resource class and its trend before changing system limits. A single high count is a clue, not proof of a leak. A stable application may legitimately retain caches or a large number of windows. A leak is a pattern in which resource usage rises with a repeatable workload and does not return to a sensible baseline after the workload and its owning UI objects are closed.

Distinguish GDI objects, USER objects, and kernel handles

GDI objects are created by graphics APIs and include bitmaps, brushes, device contexts, fonts, palettes, pens, and regions. Their handles are private to the process that created them. Each object class has a corresponding destruction function: for example, DeleteObject releases many selected GDI object types, DeleteDC releases a created device context, and ReleaseDC releases a device context obtained from a window. Calling the wrong destroy function or losing the handle can leak resources.

USER objects belong to the windowing side of the subsystem and include objects such as windows, menus, cursors, icons, hooks, and accelerator tables. They have their own lifetime rules. A window created by CreateWindow is destroyed with DestroyWindow; a menu created with CreateMenu is destroyed with DestroyMenu. A bitmap and a window are not the same kind of “handle,” even though both can be represented by opaque values.

Kernel handles from APIs such as CreateFile, CreateEvent, and CreateThread are another class. Task Manager’s ordinary Handles column is not a substitute for the GDI Objects and USER Objects columns. Likewise, process private bytes do not directly count live GDI objects. Track each signal separately so an increase in one is not misdiagnosed as another.

Windows documents per-process quotas for GUI handles, and the available amount also depends on session resources. Raising a quota can postpone exhaustion while increasing the blast radius and masking an unbounded leak. The limit is a containment boundary, not an application capacity plan. Fix ownership, then validate peak usage under realistic windows, display counts, and workload.

Establish a baseline and reproduce a bounded workload

Record process ID, image path, session, application build, Windows build, display topology, user action, and elapsed time. In Task Manager’s Details view, add the GDI objects and USER objects columns. Capture an idle baseline, run one repeatable operation such as opening and closing a document or creating a preview, then capture the count after the UI returns to idle. Repeat the same action a fixed number of times and include a close/reopen cycle.

Compare slopes, not only absolute values. If every document open adds roughly the same number of GDI objects and closing it returns to baseline, the growth may be a normal temporary allocation. If opening and closing each document leaves a residual increment that accumulates, inspect the ownership path associated with that document. If the process grows only during first use and then stabilizes, it may be a bounded cache. Capture peak count as well as current count to catch short spikes that disappear before a manual sample.

The following PowerShell snippet uses the documented GetGuiResources API to sample a target process in the caller’s current Windows session. It opens the process with query-limited-information rights, reports current and peak GDI/USER counts once per second, and closes the process handle at the end. GetGuiResources requires the target to be in the same Windows session as the caller; this is not a guarantee that it shares the same logon token or account. Run it against a known process ID and protect the output if process names are sensitive.

if (-not ('GuiResourceProbe' -as [type])) {
    Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;

public static class GuiResourceProbe
{
    [DllImport("kernel32.dll", SetLastError = true)]
    public static extern IntPtr OpenProcess(uint access, bool inheritHandle, uint processId);

    [DllImport("user32.dll", SetLastError = true)]
    public static extern uint GetGuiResources(IntPtr process, uint flags);

    [DllImport("kernel32.dll", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    public static extern bool CloseHandle(IntPtr handle);

    [DllImport("kernel32.dll", EntryPoint = "SetLastError")]
    public static extern void ClearThreadLastError(uint error);
}
'@
}

$processId = 4321
$queryLimitedInformation = 0x1000
$processHandle = [GuiResourceProbe]::OpenProcess(
    $queryLimitedInformation, $false, [uint32]$processId)
if ($processHandle -eq [IntPtr]::Zero) {
        throw [System.ComponentModel.Win32Exception]::new(
        [Runtime.InteropServices.Marshal]::GetLastWin32Error())
}

try {
    $flags = [ordered]@{
        GdiCurrent = 0
        UserCurrent = 1
        GdiPeak = 2
        UserPeak = 4
    }
    1..60 | ForEach-Object {
        $sample = [ordered]@{ Time = Get-Date; ProcessId = $processId }
        foreach ($entry in $flags.GetEnumerator()) {
            [GuiResourceProbe]::ClearThreadLastError(0)
            $count = [GuiResourceProbe]::GetGuiResources(
                $processHandle, [uint32]$entry.Value)
            $apiError = [Runtime.InteropServices.Marshal]::GetLastWin32Error()
            $sample[$entry.Key] = $count
            if ($count -eq 0) {
                $sample["$($entry.Key)LastError"] = $apiError
            }
        }
        [pscustomobject]$sample
        Start-Sleep -Seconds 1
    }
}
finally {
    [void][GuiResourceProbe]::CloseHandle($processHandle)
}

GetGuiResources reports counts for a process in the caller’s current Windows session. A process with no GUI resources can correctly return zero; a failed API call also returns zero. The sample clears the thread’s last-error value before each call and includes the captured Win32 error beside zero counts, so a normal zero count can be distinguished from an API failure. Peak flags are supported on Windows 7 and later, but not on Windows Vista or Windows Server 2008. The sample is a low-rate diagnostic poll, not an application profiler or a per-frame telemetry loop.

Attribute growth to an owning object

Once a leak is reproducible, inspect allocation and cleanup pairs in the code. Treat every successful create/acquire function as an ownership obligation. For each path, identify the object owner, the last user, and the exact release function. A common defect is releasing a HBITMAP with a window-destruction routine or calling DeleteDC on a device context that must instead be returned with ReleaseDC.

Selection rules matter. A GDI object selected into a device context should generally be deselected before deletion. Save the previous object returned by SelectObject, restore it after drawing, and then delete the created object. Deleting a bitmap while it is still selected can fail or leave the drawing path with an invalid lifetime. A cleanup path that runs only on success is another frequent leak: ensure error returns, exceptions, cancellation, and early goto/return paths all release owned objects exactly once.

Resource wrappers reduce mistakes. In C++, use small RAII types with the correct deleter for each handle class; do not use one generic wrapper that always calls DeleteObject. In .NET WinForms or WPF interop, dispose native-backed objects deterministically and avoid retaining references in event handlers, static collections, timers, or image caches after a window closes. A managed garbage collector does not automatically correct every native lifetime error promptly.

Audit object creators and destroyers as pairs. For example, CreateCompatibleDC pairs with DeleteDC, while GetDC pairs with ReleaseDC. Many GDI object creators pair with DeleteObject, but metafile and enhanced-metafile APIs have their own close/delete sequence. USER objects use their specific destroy functions. Consult the API contract for the exact object type rather than inferring from its typedef or which library created the handle.

Find the operation that grows the count

Instrument a test build around the suspected operation. Log a correlation ID, operation name, current and peak counts before/after, and success/failure. Avoid logging every draw call in production because the volume can alter timing. If the leak is in a long-lived process, compare stacks or debugger breakpoints for allocation sites. Application Verifier and native debugging tools can help locate invalid handle use and lifetime defects, but validate tool compatibility and reproduce in a safe environment.

For GUI applications, inspect window and menu lifecycle separately from rendering. Repeated window creation can leak USER objects even if bitmap counts stay flat. Repeated thumbnail or print-preview generation can leak bitmaps, memory DCs, fonts, or regions while window count remains stable. If counts rise only after display changes, test DPI changes, monitor hot-plug, theme updates, RDP reconnect, and device-context recreation. A leak can be tied to a rarely exercised destruction path rather than the common open operation.

Use Task Manager or Process Explorer for broad monitoring, then add targeted API instrumentation to narrow the object type and path. A rising GDI count is not enough to identify whether the owner is a font cache, bitmap cache, compatible DC, or region. A count that falls after an idle period may indicate delayed cleanup or cache trimming; test after deterministic close/dispose and allow background cleanup to complete before classifying.

Avoid common false fixes

Do not raise GDIProcessHandleQuota or USERProcessHandleQuota as the first response. Registry changes affect the system’s failure envelope, can let a broken application consume resources longer, and may not address session-wide exhaustion. If a vendor-supported workload genuinely needs a different quota, document evidence, test the exact setting and resource usage, and keep a rollback plan. Never recommend a value from a blog without matching it to the Windows version and process behavior.

Do not restart the process before saving evidence. Restart temporarily resets per-process counts and can hide the leak’s growth curve. Capture process ID, uptime, GDI/USER counts, workload repetitions, relevant application logs, and crash symptoms first. Do not force-close shared desktop services or terminate a process that owns unsaved user data just to clear the counter.

Do not equate all drawing failures with exhaustion. Confirm whether creation APIs fail, inspect GetLastError where documented, examine the selected object and device context, and rule out invalid handles, access violations, resource-selection errors, display-driver issues, and incorrect DPI assumptions. Resource exhaustion is one hypothesis, not an explanation for every blank control.

Verify the repair under a stress and soak test

After fixing cleanup, run the same deterministic workload and compare current and peak counts across repeated open-close cycles. Verify that counts return to a steady baseline and that no intermittent failures occur after longer soak periods. Include multiple monitors, varied DPI scaling, RDP connect/disconnect, theme changes, printing, preview, and application shutdown if those paths are supported.

Measure user-visible behavior as well as handle counts. Confirm repaint, font selection, menu creation, image rendering, and window closure remain correct. Use the same application build and test data before and after; otherwise, a lower count may simply reflect a different workload. If the program intentionally caches objects, confirm that cache size is bounded, documented, and released at an appropriate lifecycle boundary.

Incident checklist

  1. Separate GDI objects, USER objects, kernel handles, private bytes, and session-level symptoms.
  2. Capture current and peak counts with process ID, session, build, and uptime.
  3. Reproduce a repeatable operation and compare counts after close/dispose and idle cleanup.
  4. Trace every creation/acquisition to its exact type-correct release function.
  5. Check error, cancellation, exception, and shutdown paths, not only successful drawing.
  6. Fix ownership and rerun a long enough soak test before considering quota changes.

GUI resource exhaustion is usually a lifetime problem hidden behind an ordinary UI symptom. Separate the resource classes, measure trend under a controlled workload, and pair each allocation with its true owner and destructor. That approach gives a repair that survives long sessions instead of a quota increase that merely delays the next failure.

Related:

Sources:

Comments