Cutting PowerShell startup time in half
Every time I opened a new PowerShell tab in Windows Terminal, it felt sluggish. Not unbearable, but enough that I noticed the lag before my prompt appeared. I finally sat down to figure out what was eating all that time, and the fix turned out to be small changes that added up to roughly half a second saved on every single shell launch.
Measure first, guess later
PowerShell has a built in Measure-Command cmdlet that returns how long a script block takes to run. To benchmark a cold startup (profile loading included) from a running shell:
Measure-Command { pwsh -Command 'exit' } | Select-Object TotalMilliseconds
And to compare against a clean startup without your profile:
Measure-Command { pwsh -NoProfile -Command 'exit' } | Select-Object TotalMilliseconds
The difference is all profile overhead. Mine was:
- With profile: ~905ms
- Without profile: ~225ms
So my profile was adding nearly 700ms. Time to find out where.
Finding the slow parts
I wrapped each suspicious line in the profile with Measure-Command and ran them one at a time in a fresh no-profile pwsh:
Write-Host 'oh-my-posh init: ' -NoNewline
(Measure-Command {
(@(& 'C:\path\to\oh-my-posh.exe' init pwsh --config='C:\path\to\config.json' --print) -join "`n") | Invoke-Expression
}).TotalMilliseconds
Write-Host 'Terminal-Icons import: ' -NoNewline
(Measure-Command { Import-Module -Name Terminal-Icons }).TotalMilliseconds
Write-Host 'fnm env: ' -NoNewline
(Measure-Command { fnm env --use-on-cd --shell powershell | Out-String | Invoke-Expression }).TotalMilliseconds
Results:
| Thing | Cost |
|---|---|
| oh-my-posh init | ~377ms |
| Terminal-Icons import | ~284ms |
| fnm env | ~60ms |
Three culprits. None of them are doing anything wrong, they just have to run every single time a shell opens.
Fix 1: cache oh-my-posh init
oh-my-posh init pwsh spawns the executable, parses your config JSON, and prints a PowerShell script that sets up the prompt. That generated script barely changes unless you edit your config or update oh-my-posh. So I cache it:
$ompExe = 'C:\Users\ibnuh\AppData\Local\Microsoft\WindowsApps\oh-my-posh.exe'
$ompConfig = 'C:\Users\ibnuh\Documents\PowerShell\wopian.omp.json'
$ompCache = Join-Path $env:TEMP 'omp-init-wopian.ps1'
if (-not (Test-Path $ompCache) -or
(Get-Item $ompConfig).LastWriteTime -gt (Get-Item $ompCache).LastWriteTime -or
(Get-Item $ompExe).LastWriteTime -gt (Get-Item $ompCache).LastWriteTime) {
& $ompExe init pwsh --config=$ompConfig --print | Out-File -FilePath $ompCache -Encoding utf8
}
. $ompCache
The cache auto-regenerates if either the config or the oh-my-posh binary is newer than the cache file. Dot-sourcing the cached script is much faster than spawning the process again.
Fix 2: lazy load Terminal-Icons
Terminal-Icons is a formatter that renders pretty icons next to files in ls output. You only need it when you actually run ls. So instead of eagerly importing it at shell startup, I defer it to the first time ls is called:
# Remove the built-in `ls` alias first, since aliases win over functions
if (Get-Alias -Name ls -ErrorAction SilentlyContinue) {
Remove-Item Alias:ls -Force
}
$script:__TerminalIconsLoaded = $false
function ls {
if (-not $script:__TerminalIconsLoaded) {
Import-Module -Name Terminal-Icons
$script:__TerminalIconsLoaded = $true
}
Get-ChildItem @args
}
The first ls of the session pays the ~280ms import cost. Every subsequent call is instant, and shell startup doesn't pay it at all.
One thing that bit me: in PowerShell, aliases take precedence over functions during command resolution. ls is a built in alias pointing to Get-ChildItem, so my function was never called until I removed the alias.
Fix 3: cache fnm env
Same trick as oh-my-posh. fnm env prints shell code that sets up node version management hooks. Cache it, regenerate only when fnm is updated:
$fnmExe = (Get-Command fnm -ErrorAction SilentlyContinue).Source
if ($fnmExe) {
$fnmCache = Join-Path $env:TEMP 'fnm-env.ps1'
if (-not (Test-Path $fnmCache) -or
(Get-Item $fnmExe).LastWriteTime -gt (Get-Item $fnmCache).LastWriteTime) {
fnm env --use-on-cd --shell powershell | Out-File -FilePath $fnmCache -Encoding utf8
}
. $fnmCache
}
This pattern works for any tool that prints shell init code on every launch. Later I added the same thing for zoxide init powershell.
The results
| State | Startup |
|---|---|
| Original profile | ~905ms |
| After all three caches | ~440ms |
Roughly 51% faster. The remaining ~440ms is the unavoidable cost of pwsh itself loading the CLR and PSReadLine, plus parsing the cached oh-my-posh init script (which is still ~250ms even cached, since that generated script is large and sets up the prompt hooks).
Things to know
- If you edit your oh-my-posh config, the cache regenerates automatically on the next shell launch. Same for fnm and zoxide when their binaries update.
- If you want to force a regeneration, just delete the cache file from
%TEMP%. - Adding
-NoLogoto your pwsh command in Windows Terminal shaves off another ~40ms. - Check your PowerShell version with
$PSVersionTable.PSVersion. Newer releases tend to have startup improvements, so staying current helps.
If you want to go even further, you can defer oh-my-posh itself until the first prompt renders, which would get you down to around 240ms. I didn't bother because the tradeoff is that your very first prompt shows plain text for a split second before oh-my-posh swaps in, which felt worse than waiting 200ms.