Have you ever noticed your PowerShell scripts, especially those interacting with Exchange Online, gradually consume more and more RAM until your system advises of a low memory condition? You're not alone. This is a common and frustrating issue, particularly with long-running scripts that loop indefinitely.
If you are seeing your memory usage climb to 90% or higher, and it only resets when you physically close the PowerShell window, you are dealing with a Managed Heap Leak. Here is how to diagnose and kill it for good.
The Problem: Why the Leak Happens
PowerShell is built on the .NET framework, which uses a "Garbage Collector" (GC) to manage memory. Think of the GC as a janitor. In a normal script, the janitor cleans up after the script finishes.
However, in a continuous loop, the script never technically "finishes." When you combine this with the Exchange Online (EXO) module, which creates complex session objects and temporary files, the "janitor" gets confused. It sees the script is still running and assumes you might still need all that data from three hours ago, so it never throws it away.
Identifying the Leak: Your Diagnostic Tools
Before applying the fix, you can use these tools to confirm the leak is happening within the PowerShell engine:
Process Explorer (Sysinternals): Look for your
powershell.exeprocess.
Add the column for Private Bytes. If this number only goes up and never down, you have a leak. Look at the Handles in the lower pane; an ever-increasing list of "NamedPipes" is a usual suspect for Exchange session leaks.VMMap: This tool shows you exactly what is holding the memory. If the Managed Heap is the largest section and is growing, it’s a PowerShell object issue. If the Heap (native) is growing, it’s likely a bug in a DLL or the EXO module itself.
The Fix: Forcing PowerShell to Clean Up
To stop the memory from climbing indefinitely, you need to manually "call the janitor" at the end of every cycle.
Step 1: The Garbage Collection Hammer
By default, the Garbage Collector waits for "memory pressure" before it works. In a loop, we want to force it to run every time we disconnect from Exchange. Add these lines at the very end of your loop:
# Force .NET to release unused memory back to the OS
[System.GC]::Collect()
[System.GC]::WaitForPendingFinalizers()
Step 2: Use Script Blocks for Scoping
One of the best ways to help the Garbage Collector is to make sure your variables "expire." By wrapping your logic in a script block (& { ... }), all variables created inside that block are marked as "disposable" as soon as the block ends.
Step 3: Clear the Exchange Temp Files
The EXO module is notorious for leaving "session artifacts" in your Temp folder. Manually clearing these prevents the session from bloating over time.
The "Leak-Proof" Script Template
Here is how your loop should look to prevent memory bloat:
while($true) {
# 1. Wrap logic in a Script Block to isolate memory
& {
Write-Host "Starting Exchange Cycle..." -ForegroundColor Cyan
Connect-ExchangeOnline -UserPrincipalName "admin@yourdomain.com" -SilentContinue
# Retrieve your data
$data = Get-EXOMailbox -ResultSize Unlimited
# [Your Processing Logic Goes Here]
# Always disconnect within the block
Disconnect-ExchangeOnline -Confirm:$false
}
# 2. Clear out the EXO temporary session files
Get-ChildItem -Path "$env:TEMP\tmpEXO_*" -ErrorAction SilentlyContinue | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
# 3. Force the Garbage Collector to reclaim the RAM
[System.GC]::Collect()
[System.GC]::WaitForPendingFinalizers()
Write-Host "Cycle complete. Memory reclaimed. Sleeping..." -ForegroundColor Green
Start-Sleep -Seconds 300
}Simulation Script : No Leak, can I try this?
Yes, you can this script will leak in the correct memory location gradually over time but this can be slow to see:
This is the code for that simulation - to get an diagnostics you will need to leave this script for a long time.
$LeakBucket = @() # This is the "hole" in the bucket that prevents cleanup
while($true) {
# 1. Create a "heavy" object (approx 50MB of string data)
$heavyData = "X" * 50MB
# 2. To simulate a leak, we add it to a global list
# This keeps the reference "alive" so the janitor (GC) can't touch it.
$LeakBucket += $heavyData
# 3. Report current status
$mem = [Math]::Round((Get-Process -Id $PID).PrivateMemorySize64 / 1MB, 2)
Write-Host "Current Memory: $mem MB | Items in Bucket: $($LeakBucket.Count)" -ForegroundColor Red
Start-Sleep -Seconds 1
}
$LeakBucket = New-Object System.Collections.Generic.List[Byte[]]
Write-Host "Monitoring RAM... This will climb rapidly." -ForegroundColor Cyan
while($true) {
# Allocate a 200MB byte array
$byteArray = New-Object Byte[] 200MB
# Fill it with random data so Windows can't optimize/deduplicate it
(New-Object Random).NextBytes($byteArray)
# Add to the list so it can't be cleaned up
$LeakBucket.Add($byteArray)
$memGB = [Math]::Round((Get-Process -Id $PID).PrivateMemorySize64 / 1GB, 2)
Write-Host "Memory Locked in RAM: $memGB GB" -ForegroundColor Red
}
This is the process in Process Explorer where we can see its using 5.2GB of memory with the fake leak:
The Performance Graph has shows the excessive increase in the private bytes, this is the first part of the saw tooth graph:
Now this is no longer running as a Powershell tasks lets run the garbage collector with the the command below, and this should free up all those resources:
[System.GC]::Collect()If you must run a PowerShell script indefinitely, you cannot rely on the internal memory management to stay lean. By using Explicit Garbage Collection and Script Block Scoping, you can keep a script that previously took 12 GB of RAM down to a steady 500 MB.