TB

MoppleIT Tech Blog

Welcome to my personal blog where I share thoughts, ideas, and experiences.

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

  1. Predictable progress: You see a smooth progress bar based on real bytes transferred.
  2. Safe commits: Complete-BitsTransfer ensures BITS finalizes the file. You then move the temp file into place for an atomic update.
  3. 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-BitsTransfer to commit, then rename atomically from .part to the final path.
  • Otherwise, throw and Remove-BitsTransfer in catch so 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 Low for background fetches that shouldnt compete with user traffic; -Priority Foreground for 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 *.part on the same volume, then rename into place with Move-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/

← All Posts Home →