Speed Up PowerShell Startup, Without Sacrificing Functionality

When you launch a new PowerShell session, you might have some environmental configuration that is performed. For example, you might customize your shell prompt, or pre-load some Powershell modules.

Typically this is done by configuring your PowerShell profile script. Over time, you might find that you’ve added more and more functionality to your profile script. This added functionality can slow down your PowerShell startup time, resulting in a poor user experience.

At this point, you might start looking for things that you can remove from your profile script, to speed it up. Before you go that route, let me propose an alternative solution.

My Specific Scenario

I use the AWS PowerShell module to perform automation tasks against AWS cloud resources. It’s essential that I have up-to-date temporary credentials for AWS IAM, and have the AWS PowerShell module loaded. Hence, in my PowerShell profile script, I have some code to automatically refresh my temporary credentials and import the AWS PowerShell module.

Importing the monolithic AWSPowerShell.NetCore module is very slow, as it contains thousands of commands across the entire AWS service portfolio.

NOTE: There is a non-monolithic version of the AWS PowerShell SDK, that splits each AWS service into separate PowerShell modules, but that is challenging to keep updated.

To test the performance of importing the AWSPowerShell.NetCore module, I run a new PowerShell session while skipping my profile script.

pwsh -NoProfile

Next, I use the built-in Measure-Command command to test the module load time.

Measure-Command -Expression { Import-Module AWSPowerShell.NetCore }

On my high-end developer workstation, with an AMD Ryzen 3900X, 32 GB DDR4, and a 1TB Intel 660p NVMe SSD, this module takes 10.19 seconds to load.

Who wants to wait more than 10 seconds every time they launch a PowerShell session? Not me! That doesn’t even include the extra time that it would take to refresh my AWS IAM credentials.

Let’s explore how we can fix this startup performance issue, without sacrificing our objective.

Asynchronous Code Execution in PowerShell

PowerShell has a few different ways of handling asynchronous code execution. First of all, you can use PowerShell Background Jobs, but those are kind of slow. Instead, you can use PowerShell Runspaces.

First, construct a PowerShell script manager object.

$MyScript = [powershell]::Create()

Once you’ve created the PowerShell script manager, you can add individual statements, or an entire PowerShell script to it. This is where you would place the code to refresh your AWS IAM credentials, as I described earlier.

The specific implementation of this function is outside the scope of this article. If you’re interested in how I accomplished this, feel free to reach out to me on Twitter. For now, we’ll just call Start-Sleep to emulate the delay of loading the AWSPowerShell.NetCore module.

$MyScript.AddScript(@'
# Define a function to update IAM credentials
function Update-AWSIAMCredentials {
  # Do stuff here
  Start-Sleep -Seconds 10
}

# Call the function
Update-AWSIAMCredentials
'@)

Once you’ve added the script you want to execute asynchronously, create a PowerShell Runspace to execute it in.

$Runspace = [runspacefactory]::CreateRunspace()

Now you need to associate the Runspace with the PowerShell script manager. To do that, simply assign the Runspace to the Runspace property of the script manager. This tells the script where to run, but it still hasn’t been invoked.

$MyScript.Runspace = $Runspace

Register an Event Handler to Execute After Script

Before we actually invoke the script in the new Runspace we’ve created, let’s register an event handler to tell our “main” PowerShell Runspace what to do after the child Runspace completes successfully.

PowerShell has a built-in command called Register-ObjectEvent. This command allows you to handle events on .NET objects. Both the Runspace and the PowerShell script manager objects are .NET objects. Each of these objects has unique events that we can hook into.

We’ll get to this in just a minute.

Before that, how do you know which events are exposed on these objects? Just pipe both variables into the Get-Member command in PowerShell to find out!

$MyScript, $Runspace | Get-Member -MemberType Event

You should see output similar to below.

   TypeName: System.Management.Automation.PowerShell

Name                   MemberType Definition
----                   ---------- ----------
InvocationStateChanged Event      System.EventHandler`1[System.Management.Automation.PSInvocationSta… 

   TypeName: System.Management.Automation.Runspaces.LocalRunspace

Name                MemberType Definition
----                ---------- ----------
AvailabilityChanged Event      System.EventHandler`1[System.Management.Automation.Runspaces.Runspace… 
StateChanged        Event      System.EventHandler`1[System.Management.Automation.Runspaces.Runspace

For this scenario, we’ll use the InvocationStateChanged event on the PowerShell script manager to detect when execution has completed.

Now that we know which event we want to hook into, we can go back and register for the event using Register-ObjectEvent. We need just a few, key input parameters.

  • -InputObject points to the object that exposes the event we wish to hook into
  • -EventName is a string containing the name of the event on the -InputObject that we are hooking into
  • -Action parameter contains a PowerShell ScriptBlock that will execute in our “main” Runspace after the child Runspace completes
$null = Register-ObjectEvent -InputObject $PowerShell -EventNameInvocationStateChanged -Action {
  Import-Module -Name AWSPowerShell.NetCore
  Set-AWSCredential -ProfileName cbt 
}

You might notice that I prefaced the call to Register-ObjectEvent with $null =. That’s because the command will return a reference to the event registration, which I don’t need a handle to. Hence, I just discard the output by assigning the result to $null.

Check Your Results

Save all of the code that we’ve discussed into your $profile.CurrentUserAllHosts file. You can use vim or Visual Studio Code as your editor, or any other text editor you want.

The next time you fire up a PowerShell session, you should notice that it loads almost immediately. You can start working in your PowerShell terminal right away, however the background credential refresh task is still executing. If you inspect the $MyScript variable’s InvocationStateInfo child property ($ps in the screenshot), it should indicate that it has completed successfully.

I hope that this has taught you something about using PowerShell Runspaces, specifically in the context of optimizing performance of your PowerShell “profile” script. If you have any questions, feel free to leave a comment or find me on Twitter. Also, you can subscribe to my YouTube channel, where I periodically post new videos.