Reliable Downloads with BITS in PowerShell: Resumable, Throttled, Atomic
Large downloads on flaky networks can be painful: half-written files, repeated retries, and confusing logs. On Windows, you can avoid all of that with the Background Intelligent Transfer Service (BITS) and a small amount of PowerShell. BITS gives you resumable, throttled, background-friendly transfers that survive reboots and transient network failures. In this post, youll build a resilient download pattern using Start-BitsTransfer, live progress streaming, Complete-BitsTransfer, and an atomic file moveplus reliable cleanup so nothing lingers in the queue.
Why Use BITS for Downloads on Windows
- Resumable transfers: BITS uses HTTP range requests to resume where it left off after drops, reboots, or restarts.
- Background-friendly and throttled: It opportunistically uses idle bandwidth and honors system policies so you dont starve interactive traffic.
- Automatic retry/backoff: Transient errors are retried with exponential backoff; you get fewer flaky failures.
- Persistent jobs: Jobs survive process and machine restarts, so automation doesnt have to restart from scratch.
- Secure by default: Leverages Windows TLS stack and proxy settings; use HTTPS and enterprise trust stores.
Compared to Invoke-WebRequest or raw System.Net.HttpClient, BITS is purpose-built for resilient transfers on Windows servers and endpoints.
A Resilient PowerShell Pattern: Start-BitsTransfer + Progress + Atomic Move
The snippet below starts a background BITS job, streams progress, commits the download with Complete-BitsTransfer, then atomically moves it into place. If anything fails, it removes the job so the queue stays clean.
$url = 'https://files.example.com/tool.zip'
$dst = 'C:\Downloads\tool.zip'
$temp = "$dst.part"
[IO.Directory]::CreateDirectory((Split-Path -Parent $dst)) | Out-Null
try {
$job = Start-BitsTransfer -Source $url -Destination $temp -Asynchronous -Description 'tool.zip'
do {
Start-Sleep -Milliseconds 200
$job = Get-BitsTransfer -Id $job.Id -ErrorAction Stop
$pct = if ($job.BytesTotal -gt 0) { [int](($job.BytesTransferred / $job.BytesTotal) * 100) } else { 0 }
Write-Progress -Activity 'Downloading' -Status ("{0}%" -f $pct) -PercentComplete $pct
} while ($job.JobState -in 'Connecting','Transferring','Queued')
if ($job.JobState -eq 'Transferred') {
Complete-BitsTransfer -BitsJob $job
Move-Item -Path $temp -Destination $dst -Force
Write-Host ("Saved -> {0}" -f $dst)
} else {
throw ("BITS failed: {0}" -f $job.JobState)
}
} catch {
if ($job) { Remove-BitsTransfer -BitsJob $job -ErrorAction SilentlyContinue }
Write-Warning ("Download failed: {0}" -f $_.Exception.Message)
}
What this pattern gives you
- Predictable progress: You see a smooth progress bar based on real bytes transferred.
- Safe commits:
Complete-BitsTransferensures BITS finalizes the file. You then move the temp file into place for an atomic update. - Clean queue: On any error, the job is removed to avoid stuck or stale jobs.
Step-by-step
- Create the directory if it doesnt exist.
- Start the job asynchronously so you can monitor its state and stream progress.
- Loop until the job leaves active states (Connecting, Transferring, Queued).
- If the job is Transferred, call
Complete-BitsTransferto commit, then rename atomically from.partto the final path. - Otherwise, throw and Remove-BitsTransfer in
catchso nothing lingers.
Tip: Keep the temporary file on the same volume as the destination so the final move is an intra-volume rename (atomic and instant). Cross-volume moves become copies, which arent atomic.
Production Hardening: Resume, Verify, and Tune
Resuming existing jobs after restarts
Jobs persist for the current user. If your process or machine restarts, you can reattach to the job and continue.
$desc = 'tool.zip'
$job = Get-BitsTransfer -ErrorAction SilentlyContinue | Where-Object { $_.Description -eq $desc } | Select-Object -First 1
if ($job) {
Write-Host ("Found existing BITS job {0} in state {1}" -f $job.Id, $job.JobState)
if ($job.JobState -in 'TransientError','Suspended') { Resume-BitsTransfer -BitsJob $job }
} else {
$job = Start-BitsTransfer -Source $url -Destination $temp -Asynchronous -Description $desc
}
From here, reuse the same progress/commit loop shown earlier. This approach avoids restarting a large download from zero.
Surface useful error details
When BITS enters Error or TransientError, inspect the jobs error object for actionable messages.
if ($job.JobState -in 'Error','TransientError') {
$err = $job | Select-Object -ExpandProperty Error
if ($err) {
Write-Warning ("BITS error: {0}" -f $err.Description)
}
}
Log these messages to your pipeline or central logging to debug proxy issues, auth failures, or blocked endpoints.
Verify integrity with SHA-256
BITS ensures transport reliability, but you should still verify content integrity (and provenance) when you can. Publish and validate a checksum:
$expectedSha256 = '0C8E2E7C9C6E8B2E985A66F3D1A6A4C5D3E1B2C4F6A8B0C1D2E3F4A5B6C7D8E9'
$actual = Get-FileHash -Algorithm SHA256 -Path $dst
if ($actual.Hash -ne $expectedSha256.ToUpper()) {
Remove-Item -Path $dst -ErrorAction SilentlyContinue
throw "Checksum mismatch for $dst"
}
Checksum validation helps prevent deploying corrupted or tampered artifacts.
Tune priority and behavior
- Priority:
-Priority Lowfor background fetches that shouldnt compete with user traffic;-Priority Foregroundfor on-demand updates that must finish quickly. - Throttling: BITS automatically throttles on idle bandwidth and honors enterprise policies. This is ideal for shared servers.
- Timeouts: BITS retries transient failures for you; avoid adding your own aggressive retry loops that can fight with BITS backoff.
$job = Start-BitsTransfer -Source $url -Destination $temp -Asynchronous -Priority Low -Description 'tool.zip'
Download multiple files in one job
Batch related artifacts in a single job; theyll resume together.
$urls = @(
'https://files.example.com/a.zip',
'https://files.example.com/b.zip'
)
$dests = @(
'C:\Downloads\a.zip.part',
'C:\Downloads\b.zip.part'
)
$job = Start-BitsTransfer -Source $urls -Destination $dests -Asynchronous -Priority Low -Description 'bulk-download'
Use the same monitoring loop, checking $job.JobState. After Transferred, call Complete-BitsTransfer and move each .part to its final name.
Atomic updates without downtime
- Write to
*.parton the same volume, then rename into place withMove-Item -Force. - If a process might read the file mid-download, keep the temporary name hidden from consumers (e.g., write to a staging folder).
- For directories or multiple files, download to a staging tree and swap a parent directory symlink or rename the directory atomically.
PowerShell 7+ and server environments
- PowerShell 7: BITS cmdlets are Windows-only. On PS 7, import the Windows PowerShell module when needed:
if ($PSVersionTable.PSEdition -eq 'Core') {
Import-Module BitsTransfer -UseWindowsPowerShell
}
- CI/CD and services: Progress bars arent useful in non-interactive runs. Suppress them with
$ProgressPreference = 'SilentlyContinue'and emit structured logs instead. - Proxies and TLS: BITS honors system proxy and Windows certificate stores. Prefer HTTPS endpoints with valid chains; avoid disabling certificate validation.
A reusable helper function
Wrap the pattern in a function you can drop into scripts and pipelines:
function Get-ReliableBitsDownload {
param(
[Parameter(Mandatory)] [string] $Url,
[Parameter(Mandatory)] [string] $Destination,
[ValidateSet('Low','Normal','High','Foreground')] [string] $Priority = 'Low',
[string] $Sha256
)
$temp = "$Destination.part"
[IO.Directory]::CreateDirectory((Split-Path -Parent $Destination)) | Out-Null
$job = $null
try {
# Reattach if an existing matching job is found
$job = Get-BitsTransfer -ErrorAction SilentlyContinue | Where-Object { $_.Description -eq $Destination } | Select-Object -First 1
if (-not $job) {
$job = Start-BitsTransfer -Source $Url -Destination $temp -Asynchronous -Priority $Priority -Description $Destination
} else {
if ($job.JobState -in 'Suspended','TransientError') { Resume-BitsTransfer -BitsJob $job }
}
do {
Start-Sleep -Milliseconds 200
$job = Get-BitsTransfer -Id $job.Id -ErrorAction Stop
$pct = if ($job.BytesTotal -gt 0) { [int](($job.BytesTransferred / $job.BytesTotal) * 100) } else { 0 }
Write-Progress -Activity 'Downloading' -Status ("{0}%" -f $pct) -PercentComplete $pct
} while ($job.JobState -in 'Connecting','Transferring','Queued')
if ($job.JobState -eq 'Transferred') {
Complete-BitsTransfer -BitsJob $job
Move-Item -Path $temp -Destination $Destination -Force
if ($Sha256) {
$h = Get-FileHash -Algorithm SHA256 -Path $Destination
if ($h.Hash -ne $Sha256.ToUpper()) { throw "Checksum mismatch for $Destination" }
}
return $Destination
}
$err = $job | Select-Object -ExpandProperty Error
throw ("BITS failed: {0}{1}" -f $job.JobState, $(if ($err) { " - $($err.Description)" } else { '' }))
}
catch {
if ($job) { Remove-BitsTransfer -BitsJob $job -ErrorAction SilentlyContinue }
if (Test-Path $temp) { Remove-Item $temp -ErrorAction SilentlyContinue }
throw
}
}
Checklist for reliable, predictable downloads
- Use BITS for large or critical downloads.
- Asynchronous jobs with a progress loop keep users informed.
- Complete-BitsTransfer before touching the file.
- Move the file atomically on the same volume.
- Clean up failed jobs with
Remove-BitsTransfer. - Resume existing jobs when possible.
- Verify checksums for integrity and security.
What you get: fewer retries, safer updates, predictable downloads, and cleaner logsa perfect fit for CI/CD agents, scheduled maintenance jobs, and remote servers.
Make file transfers reliable in PowerShell. Read the PowerShell Advanced CookBook https://www.amazon.com/PowerShell-Advanced-Cookbook-scripting-advanced-ebook/dp/B0D5CPP2CQ/