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:

ThingCost
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

StateStartup
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 -NoLogo to 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.