Skip to main content

How to Monitor a Process's CPU Usage Over Time in Batch Script

Troubleshooting a laggy application often requires more than a single snapshot of its CPU usage. You need to know if the process is constantly consuming high CPU or if it spikes during specific tasks (like loading a file or connecting to a database). By creating a monitoring loop in a Batch script, you can track a specific process's CPU consumption over minutes or hours, logging the results to a CSV file for analysis. This historical data is the key to identifying runaway loops, resource bottlenecks, and gradual performance degradation.

This guide will explain how to build a continuous performance logger for a single process.

Why PowerShell Is Necessary

Monitoring per-process CPU in Batch faces several challenges that make PowerShell essential:

  1. wmic is deprecated since Windows 10 21H1, and its output contains invisible \r characters that corrupt Batch variables.
  2. Win32_PerfFormattedData_PerfProc_Process uses instance names with unpredictable suffixes (chrome, chrome#1, chrome#2) that change as processes start and stop. Querying by Name='chrome' returns only the first instance, missing all others.
  3. CPU percentages from WMI performance counters are per-core, not normalized to total system capacity. On an 8-core system, a process using one full core reports as 100%, not 12.5%. This requires division by the processor count, which Batch's integer-only math handles poorly.
  4. Timestamp formatting with %time% varies by locale and contains characters (colons, commas) that can break CSV parsing.

PowerShell resolves all of these issues: Get-Counter handles multiple process instances, returns floating-point values, and PowerShell provides proper arithmetic and date formatting.

Method 1: Single-Process CPU Monitor with CSV Logging

This method tracks a specific process's CPU usage over time, logging timestamped readings to a CSV file. It handles multiple instances of the same process (common for browsers and multi-process applications) and normalizes the CPU percentage to the total system capacity.

@echo off
setlocal EnableExtensions

set "ProcessName=msedge"
set "LogFile=%~dp0cpu_monitor_%ProcessName%.csv"
set "Interval=5"

title CPU Monitor: %ProcessName% - Logging every %Interval%s (Ctrl+C to stop)

:: Write CSV header if file is new
if not exist "%LogFile%" (
echo "Timestamp","CPU_Percent","Instances","Status" > "%LogFile%"
)

echo [INFO] Monitoring "%ProcessName%" every %Interval% seconds.
echo [INFO] Log file: %LogFile%
echo [INFO] Press Ctrl+C to stop.
echo.

:MonitorLoop

for /f "tokens=1-3 delims=|" %%a in ('
powershell -NoProfile -ExecutionPolicy Bypass -Command ^
"$name='%ProcessName%';" ^
"try {" ^
" $path = '\Process(' + $name + '*)\%% Processor Time';" ^
" $samples = (Get-Counter $path -ErrorAction Stop).CounterSamples;" ^
" $total = ($samples | Measure-Object CookedValue -Sum).Sum;" ^
" $cpu = [math]::Round($total / [Environment]::ProcessorCount, 1);" ^
" $count = $samples.Count;" ^
" $ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss';" ^
" Write-Output ($ts + '|' + $cpu + '|' + $count);" ^
"} catch {" ^
" $ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss';" ^
" Write-Output ($ts + '|NOT_RUNNING|0');" ^
"}"
') do (
set "Timestamp=%%a"
set "CPU=%%b"
set "Instances=%%c"
)

if "%CPU%"=="NOT_RUNNING" (
echo [%Timestamp%] %ProcessName%: Not running
echo "%Timestamp%","0","%Instances%","Not Running" >> "%LogFile%"
) else (
echo [%Timestamp%] %ProcessName%: %CPU%%% CPU across %Instances% instance(s^)
echo "%Timestamp%","%CPU%","%Instances%","Running" >> "%LogFile%"
)

timeout /t %Interval% >nul
goto :MonitorLoop

What the output looks like:

Console:

[2024-05-10 14:32:05] msedge: 8.3% CPU across 12 instance(s)
[2024-05-10 14:32:10] msedge: 2.1% CPU across 12 instance(s)
[2024-05-10 14:32:15] msedge: 45.7% CPU across 14 instance(s)

CSV (cpu_monitor_msedge.csv):

"Timestamp","CPU_Percent","Instances","Status"
"2024-05-10 14:32:05","8.3","12","Running"
"2024-05-10 14:32:10","2.1","12","Running"
"2024-05-10 14:32:15","45.7","14","Running"

Why normalization matters:

Windows performance counters report per-process CPU as a percentage of a single core. On an 8-core system, a process using 4 full cores shows as 400%, not 50%. Dividing by [Environment]::ProcessorCount normalizes to a percentage of the total system, matching what Task Manager displays and making the numbers immediately meaningful.

Why wildcard matching (%ProcessName%*):

Modern applications like browsers, VS Code, and Teams spawn multiple child processes with the same executable name. The performance counter creates separate instances: msedge, msedge#1, msedge#2, etc. The wildcard * in the counter path matches all instances, and Measure-Object -Sum totals their CPU usage for an accurate application-level reading.

Method 2: Sustained High-CPU Alert

Beyond logging, you often want to detect and alert when a process sustains high CPU usage, indicating a possible hang or runaway loop. This method tracks consecutive high readings and triggers an alert after a configurable threshold.

@echo off
setlocal EnableDelayedExpansion

set "ProcessName=msedge"
set "LogFile=%~dp0cpu_alert_%ProcessName%.csv"
set "Interval=10"
set "AlertThreshold=80"
set "ConsecutiveLimit=3"
set "HighCount=0"

title CPU Alert Monitor: %ProcessName%

if not exist "%LogFile%" (
echo "Timestamp","CPU_Percent","Consecutive_High","Alert" > "%LogFile%"
)

:: Fixed: Use !AlertThreshold!%% to safely print the % sign without parser errors
echo [INFO] Alerting if %ProcessName% exceeds !AlertThreshold!%% for %ConsecutiveLimit% consecutive checks.
echo.

:AlertLoop

set "Timestamp="
set "CPU="
set "Alert=No"

:: Fixed: Single-line PS command to avoid for/f ^ parsing bugs.
:: Fixed: \%% Processor Time (doubled %% survives CMD parsing)
:: Fixed: [Environment]::ProcessorCount is faster than Get-CimInstance
for /f "tokens=1,2" %%a in ('powershell -NoProfile -Command "$n='%ProcessName%'; $c='\Process(' + $n + '*)\%% Processor Time'; try { $s=(Get-Counter $c -EA Stop).CounterSamples; $cpu=[math]::Round(($s | Measure CookedValue -Sum).Sum / [Environment]::ProcessorCount, 1); $ts=Get-Date -Format 'yyyy-MM-dd_HH:mm:ss'; Write-Output $ts $cpu } catch { $ts=Get-Date -Format 'yyyy-MM-dd_HH:mm:ss'; Write-Output $ts 0 }"') do (
set "Timestamp=%%a"
set "CPU=%%b"
)

:: Safe threshold comparison
if defined CPU (
powershell -NoProfile -Command "if ([double]'!CPU!' -gt %AlertThreshold%) { exit 0 } else { exit 1 }" >nul
if !errorlevel! == 0 (
set /a HighCount+=1
) else (
set "HighCount=0"
)
) else (
set "HighCount=0"
)

if !HighCount! geq %ConsecutiveLimit% (
set "Alert=Yes"
echo [ALERT] %ProcessName% exceeded !AlertThreshold!%% CPU for !HighCount! checks!
echo [ALERT] Current: !CPU!%% at !Timestamp!

eventcreate /T WARNING /ID 500 /L APPLICATION /SO "CPUMonitor" ^
/D "%ProcessName% sustained high CPU: !CPU!%% for !HighCount! checks" >nul 2>&1
)

echo "!Timestamp!","!CPU!","!HighCount!","!Alert!" >> "%LogFile%"
echo [!Timestamp!] !CPU!%% CPU (consecutive high: !HighCount!^)

timeout /t %Interval% >nul
goto AlertLoop

How the consecutive-check logic works:

A single spike to 90% is often normal (loading a webpage, compiling a file). But if CPU stays above the threshold for 3+ consecutive readings, the process is likely stuck or overloaded. The HighCount variable increments on each high reading and resets to zero when CPU drops below the threshold. An alert fires only when HighCount reaches the ConsecutiveLimit.

How to Avoid Common Errors

Wrong Way: Using tasklist for CPU Data

tasklist shows memory (RAM) usage, process ID, and session information. It does not show CPU usage. There is no flag or format option that makes tasklist report CPU percentages.

Correct Way: Use Get-Counter with the \Process(*)\% Processor Time counter path for per-process CPU data.

Wrong Way: Querying a Single WMI Instance Name

:: BROKEN: only gets the first instance, misses chrome#1 through chrome#9
wmic path Win32_PerfFormattedData_PerfProc_Process where "Name='chrome'" get PercentProcessorTime

An application with 10 processes has instances named chrome, chrome#1, chrome#2, etc. Querying by exact name gets only the first one and misses 90% of the application's CPU usage.

Correct Way: Use the wildcard counter path \Process(chrome*)\% Processor Time with Get-Counter, which matches all instances and allows summing.

Problem: Counter Names Are Localized

The \Process(*)\% Processor Time counter path uses English names. On non-English Windows installations, the category and counter names are translated (e.g., German: \Prozess(*)\Prozessorzeit (%)).

Solution: If your script must run on non-English systems, use Get-CimInstance Win32_PerfFormattedData_PerfProc_Process instead of Get-Counter. WMI class names are language-independent. Note that you will still need to handle the #N instance naming manually.

Problem: Monitoring Loop Consumes CPU Itself

Each PowerShell invocation takes 1–3 seconds for process startup. If your interval is too short (e.g., 1 second), the monitoring script spends more time measuring than the interval allows, and the PowerShell startup overhead itself contributes to CPU load.

Solution: Use intervals of at least 5 seconds for per-process monitoring. For sub-second precision, use a pure PowerShell script (which starts once and loops internally) or typeperf (see Best Practices).

Best Practices and Rules

1. Choose an Appropriate Interval

Use CaseRecommended Interval
Quick diagnostic (minutes)5 seconds
Long-term trend monitoring (hours)30–60 seconds
Overnight/weekend monitoring60–300 seconds

Shorter intervals produce more data points but larger log files and more monitoring overhead.

2. Always Normalize CPU Percentages

Without normalization, a reading of 400% on an 8-core system is confusing. Always divide by [Environment]::ProcessorCount so that 100% means the entire system is saturated, matching what Task Manager displays.

3. Use typeperf for Lightweight Long-Term Logging

If you only need to log CPU data to a CSV without any Batch logic or alerting, typeperf can do this natively without launching PowerShell repeatedly:

typeperf "\Process(msedge*)\%% Processor Time" -si 5 -o msedge_cpu.csv

Press Ctrl+C to stop. Note that counter paths are localized (see Common Errors) and values are not normalized by processor count.

4. Log Instance Count

The number of process instances can change over time (browsers spawning new tabs, services forking workers). Logging the instance count alongside CPU helps correlate spikes with process creation events.

5. Include Process-Not-Running Entries

When the target process is not running, log a 0% entry with a "Not Running" status rather than skipping the row. This preserves a continuous timeline and makes it obvious when the process started and stopped.

Conclusions

Monitoring a specific process's CPU usage over time transforms your Batch scripts into diagnostic tools. By capturing a timeline of resource consumption, with proper normalization, multi-instance aggregation, and sustained-spike alerting, you gain actionable insights into application behavior that single-point snapshots cannot provide. This approach is essential for developers debugging performance issues and system administrators optimizing workloads in production environments.