How to Monitor System Performance with Performance Monitor on Windows Server 2025

Understanding what your server is actually doing under load is the foundation of capacity planning, performance troubleshooting, and proactive infrastructure management. Windows Server 2025 ships with a rich suite of built-in performance monitoring tools that require no additional software: Performance Monitor (perfmon.exe) for deep counter-based analysis and historical logging, Resource Monitor (resmon.exe) for real-time per-process and per-resource drilldown, Task Manager for quick at-a-glance health checks, and both Get-Counter and typeperf.exe for command-line and scripted data collection. Together, these tools give you everything you need to baseline normal behavior, catch anomalies early, and diagnose performance degradation before it becomes a user-impacting outage.

Prerequisites

  • Windows Server 2025 (Standard or Datacenter edition)
  • Local Administrator privileges (required for creating Data Collector Sets and accessing all counters)
  • PowerShell 5.1 or later
  • Sufficient disk space for storing performance logs (plan for at least 5 GB for a 30-day baseline)
  • Basic familiarity with Windows administrative tools

Step 1: Opening Performance Monitor and Understanding the Interface

Performance Monitor is accessed via perfmon.exe and presents two primary views: real-time monitoring (the graph view) and log-based analysis (using Data Collector Sets). The left-hand tree separates Monitoring Tools from Data Collector Sets and Reports.

# Open Performance Monitor
perfmon.exe

# Open directly to the Performance Monitor graph view
perfmon /sys

# Open Resource Monitor (per-process detail)
resmon.exe

# Open Task Manager with the Performance tab focused
# (no direct command-line switch — open Task Manager and click Performance tab)
taskmgr.exe

# Check which performance counter sets are available on this server
Get-Counter -ListSet * | Select-Object CounterSetName, CounterSetType | Sort-Object CounterSetName

The real-time graph in Performance Monitor starts empty. Click the green + button (or press Ctrl+I) to add counters. Counters are organized into counter sets (formerly called “performance objects”) such as Processor, Memory, PhysicalDisk, and Network Interface. Each set contains multiple individual counters, and some counters have multiple instances (for example, one instance per CPU core or per network adapter).

Step 2: Key Performance Counters to Monitor

Knowing which counters matter for each resource category is essential for meaningful analysis. The following counters represent the core baseline set for any Windows Server 2025 workload.

Processor Counters

# Sample processor counters in real time via PowerShell
Get-Counter "Processor(_Total)% Processor Time" -SampleInterval 2 -MaxSamples 10

# All processor counters broken out by core (instance _Total = aggregate)
Get-Counter -Counter @(
    "Processor(_Total)% Processor Time",
    "Processor(_Total)% Privileged Time",
    "Processor(_Total)% User Time",
    "SystemProcessor Queue Length"
) -SampleInterval 5 -MaxSamples 6 | ForEach-Object {
    $_.CounterSamples | Select-Object Path, @{N="Value";E={[math]::Round($_.CookedValue,2)}}
}
# Processor Queue Length > 2 per core sustained = CPU bottleneck

Memory Counters

Get-Counter -Counter @(
    "MemoryAvailable MBytes",
    "MemoryPages/sec",
    "MemoryPage Faults/sec",
    "MemoryPool Nonpaged Bytes",
    "MemoryCommitted Bytes",
    "Memory% Committed Bytes In Use"
) -SampleInterval 5 -MaxSamples 6 | ForEach-Object {
    $_.CounterSamples | Select-Object Path, @{N="Value";E={[math]::Round($_.CookedValue,2)}}
}
# Available MBytes < 10% of total RAM = memory pressure
# Pages/sec > 20 sustained = excessive paging (disk-backed memory swapping)

Disk Counters

Get-Counter -Counter @(
    "PhysicalDisk(_Total)Disk Reads/sec",
    "PhysicalDisk(_Total)Disk Writes/sec",
    "PhysicalDisk(_Total)Avg. Disk Queue Length",
    "PhysicalDisk(_Total)Avg. Disk sec/Read",
    "PhysicalDisk(_Total)Avg. Disk sec/Write",
    "PhysicalDisk(_Total)% Disk Time"
) -SampleInterval 5 -MaxSamples 6 | ForEach-Object {
    $_.CounterSamples | Select-Object Path, @{N="Value";E={[math]::Round($_.CookedValue,4)}}
}
# Avg. Disk Queue Length > 2 per spindle = disk bottleneck
# Avg. Disk sec/Read or Write > 0.020 (20ms) = high latency (concerning for HDDs, critical for SSDs)

Network Counters

# List available network interface instances first
Get-Counter -ListSet "Network Interface" | Select-Object -ExpandProperty PathsWithInstances |
    Where-Object { $_ -like "*Bytes Total/sec*" }

# Monitor a specific adapter (replace "Intel*" with your adapter name)
$Adapter = (Get-Counter -ListSet "Network Interface").PathsWithInstances |
    Where-Object { $_ -like "*Bytes Total/sec*" } | Select-Object -First 1

Get-Counter -Counter @(
    "Network Interface(*)Bytes Total/sec",
    "Network Interface(*)Packets Received Errors",
    "Network Interface(*)Packets Outbound Errors",
    "Network Interface(*)Current Bandwidth"
) -SampleInterval 5 -MaxSamples 6 | ForEach-Object {
    $_.CounterSamples | Select-Object Path, @{N="Value";E={[math]::Round($_.CookedValue,2)}}
}

Step 3: Creating a Custom Data Collector Set for Baseline Logging

A Data Collector Set (DCS) is a named collection of performance counters that logs to disk on a schedule, enabling trend analysis over days or weeks. Creating a comprehensive DCS is the cornerstone of performance baselining — you cannot know what “abnormal” looks like without first measuring what “normal” is.

# Create a Data Collector Set via logman.exe (command-line approach — scriptable)
# Define the counters to collect in a text file
$CounterFile = "C:PerfLogsbaseline_counters.txt"
@"
Processor(_Total)% Processor Time
Processor(_Total)% Privileged Time
SystemProcessor Queue Length
MemoryAvailable MBytes
MemoryPages/sec
Memory% Committed Bytes In Use
PhysicalDisk(_Total)Disk Reads/sec
PhysicalDisk(_Total)Disk Writes/sec
PhysicalDisk(_Total)Avg. Disk Queue Length
PhysicalDisk(_Total)Avg. Disk sec/Read
PhysicalDisk(_Total)Avg. Disk sec/Write
Network Interface(*)Bytes Total/sec
Network Interface(*)Packets Received Errors
Process(_Total)% Processor Time
Process(_Total)Working Set
SystemContext Switches/sec
SystemSystem Calls/sec
"@ | Set-Content $CounterFile -Encoding UTF8

# Create the Data Collector Set: 5-second sample interval, binary log format
logman create counter "WS2025_Baseline" `
    --cf "C:PerfLogsbaseline_counters.txt" `
    -o "C:PerfLogsBaselinebaseline" `
    -f bin `
    -si 5 `
    -max 512 `
    --v `
    -ow

# Start the DCS
logman start "WS2025_Baseline"

# Check its status
logman query "WS2025_Baseline"

# Stop the DCS after your collection window
logman stop "WS2025_Baseline"

# Schedule automatic start/stop (collect daily from 08:00 to 18:00)
logman update "WS2025_Baseline" -b 08:00:00 -e 18:00:00 -rf 10:00:00

Step 4: Analyzing Logs in Performance Monitor

Once you have collected a performance log (.blg file), you can load it into Performance Monitor for visual analysis. This is where you can identify patterns, correlate events, and compare a troubled time period against your healthy baseline.

# Open a binary log file directly in Performance Monitor
perfmon /sys /open "C:PerfLogsBaselinebaseline_000001.blg"

# Convert binary log to CSV for programmatic analysis
relog "C:PerfLogsBaselinebaseline_000001.blg" -f csv -o "C:PerfLogsbaseline_output.csv"

# Load and analyze the CSV with PowerShell
$PerfData = Import-Csv "C:PerfLogsbaseline_output.csv"
$PerfData | Select-Object -First 5  # Preview the structure

# Calculate average CPU usage from the log
$CPUColumn = $PerfData.PSObject.Properties.Name | Where-Object { $_ -like "*Processor Time*" }
$AvgCPU = ($PerfData.$CPUColumn | Where-Object { $_ -ne "" } | ForEach-Object { [double]$_ } |
    Measure-Object -Average).Average
"Average CPU: $([math]::Round($AvgCPU, 2))%"

Step 5: Using Get-Counter for Scripted Collection

The Get-Counter cmdlet provides PowerShell-native access to the same performance counter infrastructure. It is ideal for scripted monitoring, alerting integrations, and quick ad-hoc investigations without opening the GUI.

# Continuous monitoring with alert threshold (runs for 60 samples, 5-sec interval = 5 minutes)
$CPUCounter = "Processor(_Total)% Processor Time"
$MemCounter  = "MemoryAvailable MBytes"
$DiskCounter = "PhysicalDisk(_Total)Avg. Disk Queue Length"

Get-Counter -Counter @($CPUCounter, $MemCounter, $DiskCounter) -SampleInterval 5 -MaxSamples 60 |
    ForEach-Object {
        $Timestamp = $_.Timestamp
        $Samples   = $_.CounterSamples
        $CPU  = [math]::Round(($Samples | Where-Object { $_.Path -like "*Processor Time*" }).CookedValue, 1)
        $Mem  = [math]::Round(($Samples | Where-Object { $_.Path -like "*Available MBytes*" }).CookedValue, 0)
        $Disk = [math]::Round(($Samples | Where-Object { $_.Path -like "*Queue Length*" }).CookedValue, 2)

        $Alert = if ($CPU -gt 85) { "CPU HIGH" } elseif ($Mem -lt 1024) { "MEM LOW" } elseif ($Disk -gt 2) { "DISK QUEUE HIGH" } else { "OK" }

        [PSCustomObject]@{
            Time  = $Timestamp.ToString("HH:mm:ss")
            "CPU%" = $CPU
            "MemMB" = $Mem
            "DiskQ"  = $Disk
            Status  = $Alert
        }
    } | Format-Table -AutoSize

# Export a 1-hour sample to CSV for reporting
Get-Counter -Counter @($CPUCounter, $MemCounter, $DiskCounter) -SampleInterval 60 -MaxSamples 60 |
    ForEach-Object { $_.CounterSamples } |
    Select-Object Timestamp, Path, CookedValue |
    Export-Csv "C:PerfLogshourly_snapshot.csv" -NoTypeInformation

Step 6: Using typeperf.exe from the Command Line

typeperf.exe is the command-line equivalent of Performance Monitor’s real-time view, useful in environments where PowerShell is restricted or when you need lightweight output for shell scripting.

# Display real-time CPU and memory in the console (5-second interval, 12 samples = 1 minute)
typeperf "Processor(_Total)% Processor Time" "MemoryAvailable MBytes" -si 5 -sc 12

# Write performance data directly to a CSV file
typeperf "Processor(_Total)% Processor Time" `
         "MemoryAvailable MBytes" `
         "PhysicalDisk(_Total)Avg. Disk Queue Length" `
    -si 30 -sc 120 -f csv -o "C:PerfLogstypeperf_output.csv"

# Monitor all instances of a counter (e.g., all CPU cores individually)
typeperf "Processor(*)% Processor Time" -si 5 -sc 6

# Use a counter file list with typeperf
typeperf -cf "C:PerfLogsbaseline_counters.txt" -si 10 -sc 60 -f csv -o "C:PerfLogstypeperf_batch.csv"

Step 7: Resource Monitor for Per-Process Drilldown

When Performance Monitor identifies a bottleneck — say, CPU is sustained above 90% — Resource Monitor (resmon.exe) reveals which specific processes are responsible. It provides four tabs: CPU, Memory, Disk, and Network, each showing per-process consumption in real time.

# Open Resource Monitor
resmon.exe

# Get equivalent per-process data from PowerShell
# Top 10 processes by CPU usage
Get-Process | Sort-Object CPU -Descending | Select-Object -First 10 |
    Select-Object Name, Id, CPU, @{N="MemMB";E={[math]::Round($_.WorkingSet64/1MB,1)}} |
    Format-Table -AutoSize

# Top 10 processes by memory
Get-Process | Sort-Object WorkingSet64 -Descending | Select-Object -First 10 |
    Select-Object Name, Id, @{N="MemMB";E={[math]::Round($_.WorkingSet64/1MB,1)}}, CPU |
    Format-Table -AutoSize

# Monitor disk I/O per process (requires admin and WMI)
Get-WmiObject Win32_PerfFormattedData_PerfProc_Process |
    Where-Object { $_.Name -ne "_Total" -and $_.Name -ne "Idle" } |
    Sort-Object IODataOperationsPersec -Descending |
    Select-Object -First 10 Name, IODataOperationsPersec, IOReadBytesPersec, IOWriteBytesPersec |
    Format-Table -AutoSize

Step 8: Comparing with Task Manager’s Performance Tab

Task Manager’s Performance tab provides the fastest overview of system health and is appropriate for quick checks but lacks the historical depth and counter granularity of Performance Monitor.

# Quick health snapshot equivalent to Task Manager's Performance tab
$OS   = Get-CimInstance Win32_OperatingSystem
$CPU  = Get-CimInstance Win32_Processor
$Disk = Get-PhysicalDisk | Select-Object FriendlyName, MediaType, Size

[PSCustomObject]@{
    "CPU Model"        = $CPU.Name.Trim()
    "CPU Cores"        = $CPU.NumberOfCores
    "CPU Logical Procs"= $CPU.NumberOfLogicalProcessors
    "Total RAM (GB)"   = [math]::Round($OS.TotalVisibleMemorySize / 1MB, 2)
    "Free RAM (GB)"    = [math]::Round($OS.FreePhysicalMemory / 1MB, 2)
    "RAM Used %"       = [math]::Round((1 - $OS.FreePhysicalMemory/$OS.TotalVisibleMemorySize)*100, 1)
    "Uptime"           = (Get-Date) - $OS.LastBootUpTime
}

# View current CPU load without Performance Monitor
(Get-CimInstance Win32_Processor).LoadPercentage

# Quick disk health overview
Get-PhysicalDisk | Select-Object FriendlyName, MediaType, HealthStatus, OperationalStatus,
    @{N="SizeGB";E={[math]::Round($_.Size/1GB,1)}}

Effective performance monitoring on Windows Server 2025 is not a one-time activity — it is a continuous practice built on the foundation of a well-defined baseline. By capturing 30 days of normal operation data via Data Collector Sets, you create the reference point against which all future anomalies can be measured. Layer in real-time alerting through Get-Counter-based scripts, use Resource Monitor to triage active problems down to the responsible process, and consult the Performance Monitor log viewer for post-incident analysis. This combination — baseline, alert, triage, analyze — gives you the tools to catch performance problems early, diagnose them quickly, and justify infrastructure investments with concrete data rather than gut feeling.