HomeNewsletterCommunityMVP FeedToolsArchiveBlogToday's NewsAboutServicesQuick Links Subscribe free
← Back to Blog
Windows Update Windows UpdateBITSDelivery OptimizationPowerShellTroubleshootingWindows 11Group PolicyEvent Log

The update stuck at 0% is a BITS job, not Windows Update: read the transfer queue first

IA
Imran Awan
23 August 2026

A user reports that Windows Update has been sitting at 0% for two days. You open Settings, see the same 0%, and reach for the reflex: stop wuauserv, stop bits, rename SoftwareDistribution, start everything, retry. Sometimes it works. You never find out why.

The reason you never find out is that the reflex deletes the only record of what went wrong. On a modern Windows client the progress bar you are staring at is not owned by Windows Update at all. It is a summary of work being done by two other services with their own queues, their own state machines, and their own logs — and one of those queues will tell you the answer in about four seconds if you read it before you break it.

This article is about reading it. Not fixing anything yet. Reading it.

The short version

"Stuck at 0%" is a claim about a transfer queue, so read the transfer queue. Get-BitsTransfer -AllUsers from an elevated prompt enumerates every BITS job on the device and its state; Microsoft documents nine job states, and the difference between TRANSIENT_ERROR and ERROR is the whole diagnosis. A job in transient error retries on a documented 600-second minimum delay and only gives up after a documented 14-day no-progress timeout, so a "stuck" download can be a perfectly healthy job that has been failing quietly since last Tuesday. Meanwhile Windows Update content on Windows 10/11 is fetched by Delivery Optimization (DoSvc, dosvc.dll), not BITS — Microsoft's own documentation says configuring Download Mode 100 makes "the download bypass Delivery Optimization and use BITS instead", which tells you which service is normally driving. And crucially: BITS error detail is only readable while the job is in the error state. Restart the service and the evidence is gone.

The problem: 0% is a number nobody has verified

Take the symptom apart. "Stuck at 0%" bundles four separate claims, and every one of them can be false.

  1. That a download job exists. It might never have been created.
  2. That the job is not progressing. Nobody sampled it twice.
  3. That the job belongs to Windows Update. It might belong to the Configuration Manager client, Chrome's updater, or OneDrive.
  4. That the failure is a fault. A job that has been in transient error for three days is behaving exactly as Microsoft documents it should.

Here is what the queue actually looked like on the Windows 11 25H2 corporate device I used to write this article. Nobody had reported a problem on it. This is a device somebody would describe as fine.

PowerShell (elevated) - read-only enumeration of the BITS queue
Get-BitsTransfer -AllUsers | Select-Object JobId, DisplayName, TransferType, JobState, OwnerAccount, BytesTransferred, BytesTotal | Format-List # -AllUsers requires administrative credentials. Without it you see only your own jobs, # which is exactly why an engineer running unelevated sees an empty queue and concludes # "there is no BITS job" on a device that has two. JobId : f3566921-3d3e-4256-8077-cd46f9395384 DisplayName : CCM Message Upload {D55C9403-1865-4EAB-8B90-261AFE2255A7} TransferType : Upload JobState : Error OwnerAccount : NT AUTHORITY\SYSTEM BytesTransferred : 0 BytesTotal : 5700 JobId : 9a2a3036-3a29-4054-a0f7-8fb187eec0fe DisplayName : CCM Message Upload {31EDF335-B5BA-4EEE-9B46-E285CDB9AC24} TransferType : Upload JobState : Error OwnerAccount : NT AUTHORITY\SYSTEM BytesTransferred : 9072 BytesTotal : 9073 # Two jobs. Both dead. Both owned by SYSTEM, not by a user. # The first has moved 0 of 5,700 bytes. That is literally 0%. # The second has moved 9,072 of 9,073 bytes and failed one byte short.

Real output, captured read-only from a live Windows 11 Enterprise 25H2 device (build 26200.9168) during research for this article. Job GUIDs are the device's own.

Read the second job again. It is 99.99% complete and permanently failed. If a user had been watching a progress bar fed by that job, they would have reported it as "stuck at 100%" — and every troubleshooting article on the internet would have sent them looking for a servicing-stack problem that does not exist.

Neither of these is Windows Update. CCM is the Configuration Manager client, and these are upload jobs — state messages going back to a management point. That matters enormously, because the reflex fix for a stuck Windows Update download is to flush the whole BITS queue. Doing that here would have silently discarded a client's pending inventory data, and the ConfigMgr symptom ("this device stopped reporting") would have surfaced days later with no connection to the update ticket that caused it.

Watch out: Microsoft documents that BITS error information is only available while the job is in the error state — BG_E_ERROR_INFORMATION_UNAVAILABLE (0x8020000F) is returned because "error information is only available when the state of the job is BG_JOB_STATE_ERROR". Stopping the BITS service, cancelling the job, or clearing the queue destroys the one field that names the cause. Capture the error first, every time. There is no way to get it back afterwards.

Why it happens: three queues, two services, one progress bar

Who actually downloads a Windows update

Three components share the work behind that progress bar, and they are genuinely separate.

The Update Session Orchestrator (UsoSvc) decides that a scan or a download should happen and drives the session. It does not move bytes.

Delivery Optimization (DoSvc) is what Microsoft describes as "a reliable HTTP downloader with a cloud-managed solution", and its documented content list includes Windows Update feature and quality updates, drivers, Store apps, Defender definitions, Intune Win32 apps, Microsoft 365 apps and Edge updates. On a Windows client it is on by default in LAN peering mode.

BITS (qmgr.dll, hosted in svchost.exe -k netsvcs) is the general-purpose background transfer queue. Plenty of things still use it — the ConfigMgr client, browser updaters, OneDrive's config fetches — and it is also Delivery Optimization's documented fallback path.

The cleanest proof of that split is in Microsoft's own Delivery Optimization reference, in the note deprecating Download Mode 100: "When you configure Bypass (100), the download bypasses Delivery Optimization and uses BITS instead." You only need a setting to make a download use BITS if it was not using BITS to begin with.

Context: this is why "an update stuck at 0% is a BITS job" is a useful heuristic rather than a rule. It is a BITS job often enough that BITS is the first queue to check — enumerating it costs one command and no risk. But on a modern client the Windows Update payload itself is usually Delivery Optimization's work, so if BITS is empty you have not finished, you have only eliminated one suspect.

The BITS state machine, which is the actual diagnosis

Microsoft groups the nine documented BITS job states into four classes: starting, action, transferred and final. Every state tells you something specific, and two of them are routinely confused with each other.

Job stateWhat Microsoft documents it to meanWhat it tells you at 0%
SUSPENDEDThe job is paused. BITS automatically suspends a job when it is created; it stays suspended until the caller resumes, completes or cancels it. This is also the documented starting state.The caller created the job and never resumed it. Nothing has been attempted. This is a bug in the calling application, not a network problem.
QUEUEDThe job is in the queue waiting to run — "ready to be scheduled, possibly waiting for the BITS scheduler or waiting for the user to log in". A job also returns here if the user logs off mid-transfer.BITS has not given it a time slice yet, or it is waiting on a sign-in. Check what else is in the queue at higher priority.
CONNECTINGBITS is trying to reach the server. On success it becomes TRANSFERRING; on failure it becomes TRANSIENT_ERROR.A momentary state. If you catch it here repeatedly, name resolution or the proxy is the place to look.
TRANSFERRINGBITS is actively moving data.It is working. If the byte count is not rising, you have a throughput problem, not a stuck job.
TRANSIENT_ERRORA recoverable error. BITS retries on the job's minimum retry delay. Documented causes include network policy ("the current network is too expensive"), battery saver, game mode, and no internet connectivity.This is the state that looks like "stuck for days". It is retrying. It is not broken yet. Find the blocker.
ERRORA nonrecoverable error — the service cannot transfer the file and will not retry. Reached by exceeding the no-progress timeout, or by a fatal error such as needing a helper token.It has given up. Read the error now, before anything restarts.
TRANSFERREDThe job succeeded but the caller has not acknowledged it. Downloaded files are not available to the client until Complete is called.The transfer worked and the application is not collecting. Look at the caller, not the network.
ACKNOWLEDGEDFinal state after the caller called Complete.Done and gone.
CANCELLEDFinal state after Cancel. All transferred and partially transferred files are deleted.Done and gone — and the partial bytes were thrown away.
Gotcha: Microsoft states that once a job reaches a final state "it won't move out of the final state and won't show up in a job enumeration". So an empty Get-BitsTransfer -AllUsers is not proof that nothing happened — it is equally consistent with a job that already succeeded, or one that something cancelled ten seconds before you looked. Absence of a job is not evidence. Pair the queue read with the event log every time.

Why a job in transient error retries silently for days

This is the single most useful piece of BITS arithmetic, and it explains almost every "stuck for a week" ticket.

The minimum retry delay defaults to 600 seconds — 10 minutes. That is how long BITS waits after a transient error before trying again. The floor is 5 seconds; anything lower is silently raised to 5.

The no-progress timeout defaults to 1,209,600 seconds — 14 days. That is how long BITS keeps retrying, from the first transient error, before it moves the job to ERROR. Any actual progress resets the timer.

And the JobInactivityTimeout Group Policy defaults to 90 days, after which BITS considers a pending job abandoned, deletes it, and deletes its downloaded files from disk.

The documented default timeline of a "stuck" download first transient errorretry every 600s14 days no progressERROR90 days inactive: job and files deleted

Read that timeline against a helpdesk ticket. A user who says "it's been stuck for a couple of days" is describing a job that is, by Microsoft's own defaults, still inside its normal retry window. Nothing has failed. Something is blocking it, and the blocker has been sitting there quietly for 48 hours while BITS politely tried 288 times.

Microsoft also documents when BITS gives up immediately instead: it does not retry if a network disconnect or a disk lock error occurred (chkdsk running, for example), or if the MaxInternetBandwidth Group Policy is set to zero. A bandwidth limit of zero is documented to place all background jobs in transient error with BG_E_BLOCKED_BY_POLICY.

Priority, and how one dead job starves the rest

BITS has four documented priorities. FOREGROUND (value 0) competes for bandwidth with everything else on the machine and "can impede the user's network experience". HIGH, NORMAL and LOW are all background priorities that "use idle network bandwidth of the client"; NORMAL is the default.

The scheduling remark is the part worth memorising: "Lower-priority jobs don't receive transfer time until all higher-priority jobs are transferred, or are in an error state." Jobs at the same priority share time slices round-robin so a large job cannot block a small one, and all foreground jobs run concurrently with one background job. But a high-priority job stuck in an action state — queued, connecting, transferring at zero throughput — holds the line ahead of everything beneath it until it finally errors out. So the job you are chasing may be perfectly healthy and simply last in a queue behind something else, which is why you enumerate the whole queue rather than one job.

The binaries, and one path worth double-checking

All values below were read from the lab device, not from memory.

FileRoleWhere it lives on the lab device (25H2, build 26200.9168)
qmgr.dllThe BITS queue manager — the service DLL itself. Its FileDescription is literally "Background Intelligent Transfer Service". This is the code that owns the job list, the state machine and the scheduler.C:\Windows\System32\qmgr.dll, version 7.8.26100.8115. Registered as the ServiceDll under the BITS service key.
BitsProxy.dll"Background Intelligent Transfer Service Proxy" — the COM proxy/stub surface callers marshal through to reach the BITS interfaces.C:\Windows\System32\BitsProxy.dll, version 7.8.26100.5074.
dosvc.dllDelivery Optimization — the service DLL behind DoSvc. FileDescription: "Delivery Optimization". This is what actually pulls Windows Update content.C:\Windows\System32\dosvc.dll, version 10.0.26100.7309.
bitsadmin.exe"BITS administration utility". Still shipped. Useful when you need /list /allusers /verbose from a plain command prompt, but the PowerShell cmdlets return objects you can sort and diff.C:\Windows\System32\bitsadmin.exe, version 7.8.26100.1.
svchost.exeThe host process. BITS runs as svchost.exe -k netsvcs -p as LocalSystem; Delivery Optimization runs as svchost.exe -k NetworkService -p as NT Authority\NetworkService. Neither has a process named after itself in Task Manager.C:\Windows\System32\svchost.exe

One caution, because this class of mistake is easy to make. Microsoft's reference pages for the BITS COM methods list the implementing DLL as QmgrPrxy.dll. On this build there is no qmgrprxy.dll in System32 at all — a filtered listing for qmgr* returns exactly one file, qmgr.dll, and the proxy that is present is named BitsProxy.dll. If your detection script tests for a binary's existence, test the path on the build you are targeting rather than trusting a reference page's requirements table.

How to verify: the read-only order that preserves the evidence

Every step below is read-only. Nothing here restarts a service, resumes a job or deletes a file. Work through them in order and stop at the first one that answers the question.

Step 1 — Confirm which services are even running

PowerShell - service and binary identity, read-only
Get-Service bits, dosvc, wuauserv | Format-Table Name, Status, StartType -AutoSize Name Status StartType ---- ------ --------- bits Running Automatic dosvc Running Automatic wuauserv Running Manual # This is the healthy baseline. wuauserv on Manual is normal - it is trigger-started. # Do not "fix" that. Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\BITS\Parameters' | Select-Object ServiceDll ServiceDll : C:\WINDOWS\System32\qmgr.dll # The service's own registry key names the DLL. This is how you prove which # binary is in play instead of guessing from a blog post. (Get-Item C:\Windows\System32\qmgr.dll).VersionInfo | Format-List FileVersion, FileDescription FileVersion : 7.8.26100.8115 (WinBuild.160101.0800) FileDescription : Background Intelligent Transfer Service

Note the version scheme: 7.8.x, not 10.0.x. BITS carries its own component version independent of the OS build, which is why "is BITS up to date" is not a question the OS build number answers.

Step 2 — Enumerate the queue, elevated

Run the Get-BitsTransfer -AllUsers command from the first console block. The -AllUsers switch is documented to require administrative credentials, and without it the cmdlet returns only the calling user's jobs. Almost every job that matters for patching is owned by NT AUTHORITY\SYSTEM, so an unelevated run is guaranteed to under-report. If the parameter fails, you are not elevated — that is the error, not an empty queue.

Step 3 — Is it progressing? Sample twice

Nobody can tell a slow transfer from a dead one with a single reading. Take two, sixty seconds apart, and subtract.

PowerShell - two-sample progress delta, read-only
$a = Get-BitsTransfer -AllUsers | Select-Object JobId, DisplayName, JobState, BytesTransferred Start-Sleep -Seconds 60 $b = Get-BitsTransfer -AllUsers | Select-Object JobId, DisplayName, JobState, BytesTransferred foreach ($j in $b) { $prev = $a | Where-Object JobId -eq $j.JobId $delta = if ($prev) { $j.BytesTransferred - $prev.BytesTransferred } else { 'new job' } '{0,-46} {1,-16} {2}' -f $j.DisplayName, $j.JobState, $delta } # A non-zero delta means it is moving and this is a throughput question. # A zero delta with state Transferring means the socket is open and dry. # A zero delta with state TransientError means it is between retries - # remember the documented default is one attempt every 600 seconds, so a # 60-second sample will usually show zero even on a job that is retrying fine.

Step 4 — Read the error, while it still exists

For a job in Error, pipe it to Format-List * and capture the whole object to a file before you touch anything. The fields you want are the job state, the error description and the file-level detail. Remember the constraint from earlier: this information is documented as available only while the job is in the error state.

The BITS-specific codes are worth knowing on sight, because several of them point straight at a corporate network rather than at Windows.

CodeNameWhat it actually means for you
0x8020003EBG_E_BLOCKED_BY_POLICY"Group Policy settings prevent background jobs from running at this time." Go straight to the BITS policy key. A bandwidth limit of zero produces exactly this.
0x80200010BG_E_NETWORK_DISCONNECTEDThe adapter is inactive or disconnected, and all jobs are placed in transient error. One VPN drop explains a whole queue.
0x80200013BG_E_INSUFFICIENT_RANGE_SUPPORTThe server does not support the Content-Range header — and Microsoft explicitly notes you also get this "if an intermediate proxy is removing the Content-Range or Content-Length header". This is the classic inspecting-proxy failure.
0x80200011BG_E_MISSING_FILE_SIZENo Content-Length returned. BITS only transfers static content and requires that header. Same proxy family of causes.
0x80190197BG_E_HTTP_ERROR_407The proxy requires authentication. A SYSTEM-owned job has no user credentials to give it, which is why these fail on exactly the devices where a browser works fine.
0x80200049 / 0x80200050BG_E_TOO_MANY_JOBS_PER_USER / ..._PER_MACHINEThe per-user (default 60) or per-machine (default 300) job limit is exceeded, so new jobs cannot be created at all. Abandoned jobs accumulating for 90 days will do this.
0x80200054BG_E_MAXDOWNLOAD_TIMEOUTThe job exceeded its maximum download time or the MaxDownloadTime policy (default 90 days).
0x8020000FBG_E_ERROR_INFORMATION_UNAVAILABLEYou asked too late. The job is no longer in the error state, or the client exited. This is the code that tells you the reflex fix already ate your evidence.

Step 5 — Read BITS' own log channel

BITS has a real Event Viewer channel, and Microsoft's own error documentation points you at it by name: the description for BG_E_VALIDATION_FAILED says to "use Event Viewer to view the Application Logs\Microsoft\Windows\Bits-client\Operational log". On the lab device that channel was enabled with 1,238 records sitting in it, entirely unread. Its sibling Analytic channel was disabled, which is the default.

Event log channel for every table row belowMicrosoft-Windows-Bits-Client/Operational
Event IDMessage text observed on the lab deviceWhat it tells you
3"The BITS service created a new job. Transfer job: name / Job ID: guid / Owner: account / Process Path: full path to exe / Process ID: pid"The single most valuable event in the channel. It names the process that asked for the transfer. This is how you find out that your "Windows Update" job was created by something else entirely.
4"The transfer job is complete. User: ... Transfer job: ... Job ID: ... Owner: ... File count: 1"Terminal success. Pair it by Job ID with the matching event 3 to get the full lifetime of a transfer.
59"BITS started the name transfer job that is associated with the URL URL."An attempt began, and it gives you the exact URL. Feed that URL to your proxy team rather than a hostname you guessed.
60"BITS stopped transferring the name transfer job that is associated with the URL URL. The status code is 0x0."An attempt ended. Logged at Information level with status 0x0 — a clean stop, not a failure. Do not alert on event 60 by ID alone.
61Same sentence as 60, at Warning level, with a non-zero status. Real example from the device: "...the https://g.live.com/odclientsettings/ProdV2... URL. The status code is 0x80072EFD."This is the failure event. The status code is the reason. 61 versus 60 is the distinction that matters, not the message text.
209"High performance property for BITS job name with ID guid Enabled."A caller opted its job into high-performance mode. Useful context when one job appears to be monopolising the queue.
306"The BITS service loaded the job list from disk." (Verbose)The service started and rehydrated the persisted queue. Five of these on the lab device — that is your count of BITS service starts in the retained window.
310"The initialization of the peer helper modules failed with the following error: 0x80070032." (Warning)A peer-caching helper did not initialise. Noise in most environments, and specifically not a Windows Update failure. Do not chase it because it is coloured yellow.
16403Rendered with an empty message body on this device, at Information level, provider Microsoft-Windows-Bits-Client.Honest answer: the message template did not resolve. It appeared 70 times in the sample and carries no readable text, so do not build alerting on it.

The distribution itself is diagnostic. Across the most recent 400 records the counts were: 60 appearing 71 times, 4 appearing 71 times, 3 appearing 70 times, 16403 appearing 70 times, 59 appearing 70 times, 209 appearing 34 times, 306 and 310 five times each, and 61 just four times. Seventy starts, seventy-one stops, seventy creations and seventy-one completions — a queue doing its job — against four warnings. Those four are the whole signal.

PowerShell - census the channel, then read only the failures
Get-WinEvent -LogName 'Microsoft-Windows-Bits-Client/Operational' -MaxEvents 400 | Group-Object Id | Sort-Object Count -Descending | Format-Table Name, Count -AutoSize Name Count ---- ----- 60 71 4 71 3 70 16403 70 59 70 209 34 306 5 310 5 61 4 # Now read the four that matter. Event 61 is the warning-level stop. (Get-WinEvent -FilterHashtable @{ LogName='Microsoft-Windows-Bits-Client/Operational'; Id=61 } -MaxEvents 1).Message BITS stopped transferring the NucleusUpdateRingConfigJSON transfer job that is associated with the https://g.live.com/odclientsettings/ProdV2?OneDriveUpdate=... URL. The status code is 0x80072EFD. # 0x80072EFD decomposes: the 0x8007 prefix is a wrapped Win32 error - BITS uses the # documented HRESULT_FROM_WIN32 macro for these - and the low word 0x2EFD is 12029, # which Microsoft documents as ERROR_INTERNET_CANNOT_CONNECT: "The attempt to # connect to the server failed." Not a BITS bug. A reachability problem.

That decomposition trick is worth keeping. Any BITS status code beginning 0x8007 is a wrapped Win32 error and its low sixteen bits are the real code; codes beginning 0x8020 are BITS' own; codes beginning 0x8019 are wrapped HTTP status codes (0x80190197 is 407, 0x80190194 is 404).

Step 6 — Check whether policy is throttling or blocking the transfer

This is where the lab device produced the best trap in the whole article.

Parent key for every value in the table belowHKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Windows\BITS
ValueMeaningWhat to look for
EnableBitsMaxBandwidthThe on/off flag for the ADMX policy "Limit the maximum network bandwidth for BITS background transfers" (BITS_MaxBandwidth in Bits.admx).Read this first. On the lab device it is 0. Microsoft documents that if the policy is disabled or not configured, "BITS uses all available unused bandwidth" — so every throttle value sitting beside it is inert.
MaxBandwidthValidFromStart hour of the throttling window (0-23). Maps to the CSP setting BandwidthThrottlingStartTime, documented default 8.9 on the lab device. A 9am-to-5pm window that is not switched on.
MaxBandwidthValidToEnd hour of the throttling window (0-23). Maps to BandwidthThrottlingEndTime, documented default 17.17 on the lab device.
MaxTransferRateOnScheduleThe in-window background transfer cap. Maps to BandwidthThrottlingTransferRate. Units are kilobits per second, documented default 1000. Foreground transfers are explicitly unaffected.1000 on the lab device. This is the number an engineer sees and misreports as "we throttle patching to 1 Mbps in business hours".
MaxTransferRateOffScheduleThe out-of-window background cap, in Kbps.9999 on the lab device.
JobInactivityTimeoutDays a pending job may stay inactive before BITS declares it abandoned, deletes it, and deletes its downloaded files. Any property change or successful download resets the timer.Absent on the lab device, so the documented default of 90 days applies. If it is present and short, that alone can explain vanishing jobs.
Tip: a throttle value of 0 is not "no limit" — Microsoft documents that specifying zero prevents BITS transfers from occurring and places all background jobs in transient error with BG_E_BLOCKED_BY_POLICY, releasing them to QUEUED only after the time interval expires. If you inherit a baseline with a zero in MaxTransferRateOnSchedule and an enable flag of 1, you have found your root cause and it is a policy, not a device.
PowerShell - the enable-flag trap, read-only
Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\BITS' | Select-Object EnableBitsMaxBandwidth, MaxBandwidthValidFrom, MaxBandwidthValidTo, MaxTransferRateOnSchedule, MaxTransferRateOffSchedule | Format-List EnableBitsMaxBandwidth : 0 MaxBandwidthValidFrom : 9 MaxBandwidthValidTo : 17 MaxTransferRateOnSchedule : 1000 MaxTransferRateOffSchedule : 9999 # Four values that describe a 9-5 throttle at 1000 Kbps, and one flag that says # the whole policy is Disabled. The values are residue from a previous baseline. # Report the flag, not the numbers. This is the most common wrong conclusion # drawn from this key.

Step 7 — If BITS is clean, look at Delivery Optimization

Delivery Optimization keeps its own queue with its own vocabulary, and it does not appear in Get-BitsTransfer at all. The DeliveryOptimization PowerShell module is the way in.

PowerShell - Delivery Optimization state and effective config, read-only
Get-DODownloadMode Lan Get-DOConfig | Format-List DownloadMode : Lan DownloadModeProvider : MdmProvider DownBackLimitBps : 1024000 DownBackLimitBpsProvider : MdmProvider DownloadForegroundLimitBps : 1024000 DownloadForegroundLimitBpsProvider : MdmProvider DownBackLimitPct : 25 DownBackLimitPctProvider : MdmProvider DownloadForegroundLimitPct : 25 DownloadForegroundLimitPctProvider : MdmProvider MaxUploadRatePct : 50 MaxUploadRateProvider : DefaultProvider UploadLimitMonthlyGB : 5120 UploadLimitMonthlyGBProvider : DefaultProvider # Every line carries its own provider. That column is the point of this command: # it tells you whether a limit came from MDM or from a built-in default. Get-DeliveryOptimizationStatus | Select-Object FileId, FileSize, TotalBytesDownloaded, PercentPeerCaching, Status, Priority, DownloadMode FileId : 95D2EE60-...-BAE16D758A87_16_0_20131_20152_STREAM_X64_X_NONE FileSize : 2786925108 TotalBytesDownloaded : 226492416 PercentPeerCaching : 33.6805555555556 Status : Caching Priority : Background DownloadMode : Lan # Status: Caching. Priority: Background. Neither word appears anywhere in the BITS # state machine. Different service, different queue, different vocabulary.

Compare the runtime numbers with the policy that produced them. On this device the MDM policy under the PolicyManager key sets DOMaxBackgroundDownloadBandwidth to 1000, and Microsoft documents that setting's unit as kilobytes per second. Get-DOConfig reports DownBackLimitBps as 1024000 — exactly 1000 × 1024 bytes — and attributes it to MdmProvider. That agreement is your proof the policy is genuinely in force, which is precisely the check the BITS key failed.

Parent key for the effective MDM Delivery Optimization values on this deviceHKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\PolicyManager\current\device\DeliveryOptimization
ValueMeaningWhat to look for
DODownloadModeWhich sources the client may use. Documented values: HTTP Only (0), LAN (1, the client default), Group (2), Internet (3), Simple (99), Bypass (100, deprecated).1 on the lab device. If you find 100, downloads are bypassing Delivery Optimization and using BITS — and Microsoft warns this "can cause some content to fail to download".
DOMaxBackgroundDownloadBandwidthAbsolute background cap in kilobytes per second across all concurrent DO downloads. Documented default 0, meaning dynamic adjustment.1000 on the lab device. Cross-check it against DownBackLimitBps from Get-DOConfig to confirm it is live.
DOSetHoursToLimitBackgroundDownloadBandwidthBusiness-hours percentage throttle, stored as an XML range. Microsoft documents that when this is set it takes precedence over the flat percentage setting.On the lab device: <Range><RangeStartTime>8</RangeStartTime><RangeEndTime>17</RangeEndTime><PercentageMaxDownloadBandwidthIn>25</...><PercentageMaxDownloadBandwidthOut>25</...></Range>. Read this before you read the flat percentage, or you will report the wrong number.
DODelayBackgroundDownloadFromHttpSeconds to delay falling back to the HTTP source so peers get a chance first.60 on the lab device. Sixty seconds of a large download looking like it has not started is expected behaviour here, not a fault.
DOMinFileSizeToCacheMinimum file size in MB eligible for peer caching. Documented default 50 MB.10 on the lab device.
DOAllowVPNPeerCachingWhether peering is permitted while a VPN is detected. Default is not to allow it.0. On a remote worker this means no peers at all, so every byte comes from the HTTP source.

One honest gap, deliberately left as a gap: I am not going to give you the on-disk path of the Delivery Optimization cache. Microsoft's reference documents the default location only as the operating system drive "through the %SYSTEMDRIVE% environment variable", redirectable with DOModifyCacheDrive to a drive letter or folder. It does not publish the folder. Third-party write-ups disagree with each other about it. If you need the cache gone, use the supported cmdlet Delete-DeliveryOptimizationCache rather than deleting a directory you found on a forum.

Step 8 — Separate active hours from bandwidth policy

This conflation costs real hours. Microsoft's definition is unambiguous: "Active hours identify the period of time when you expect the device to be in use. Automatic restarts after an update occur outside of the active hours." Default 8 AM to 5 PM, configured through SetActiveHours, ActiveHoursStart and ActiveHoursEnd under the WindowsUpdate policy key. Active hours governs restarts. It does not throttle, delay or block a transfer, and nothing in its documentation touches bandwidth.

The settings that genuinely throttle by time of day are the BITS MaxBandwidthValidFrom/ValidTo window and Delivery Optimization's DOSetHoursToLimitBackgroundDownloadBandwidth. On the lab device all three concepts point at roughly the same working day — BITS at 9-17 (disabled), Delivery Optimization at 8-17 (active, 25%), and active hours unconfigured by policy so the local 8 AM-5 PM default applies to restarts only. Three separate mechanisms, one shared-looking window, and only one of them was actually limiting bytes.

The fix: what to do once you know which state the job is in

Now, and only now, act. The action is determined entirely by the state you found.

  1. SUSPENDED, and it is not new. Something created a job and never resumed it. Resume-BitsTransfer will start it, but the real fix is on the calling application's side — a job that BITS suspended at creation and nobody resumed is a caller bug. Note that resuming a job with no files returns BG_E_EMPTY (0x80200003) and leaves it suspended.
  2. QUEUED for a long time. Look at what sits above it. A higher-priority job stuck in an action state holds the queue until it errors. Fix or clear the blocker, not the victim.
  3. TRANSIENT_ERROR. Do not just resume it — you will get the same failure ten minutes later. Work the documented blockers first: is the connection metered or "too expensive", is the device in battery saver or game mode, is there actual internet connectivity, is a bandwidth policy set to zero, is an inspecting proxy stripping Content-Range. Resuming before you clear the blocker resets nothing except your own clock.
  4. ERROR. Capture the error object to a file, then choose deliberately between two documented behaviours. Complete-BitsTransfer keeps fully downloaded files and discards incomplete ones. Remove-BitsTransfer cancels — and Microsoft documents that cancelling deletes all completely and partially transferred files. On a 3 GB feature update on a branch-office link, that distinction is an afternoon.
  5. TRANSFERRED. The transfer already succeeded. Files are not released to the caller until Complete is called, so this is an application problem and restarting BITS will not help.
  6. BITS queue empty, Delivery Optimization Caching or Downloading. Your download is fine and being throttled or delayed. Check the DO bandwidth policy and the HTTP-fallback delay before touching anything.
  7. BITS queue empty, Delivery Optimization empty, no events. Nothing was ever asked for. The problem is upstream in the scan or the orchestrator, not in a transfer queue at all — and now you know that instead of guessing.
Watch out: the one-liner that circulates as the fix for this — Get-BitsTransfer -AllUsers | Remove-BitsTransfer — cancels every job on the device, and cancellation is documented to delete all transferred and partially transferred files. On the lab device that command would have destroyed two pending Configuration Manager state-message uploads that had nothing to do with any update. If you must clear the queue, remove the specific JobId you diagnosed, after you have written the queue snapshot to a file.

Only after all of that does a service restart earn its place, and it is a narrower tool than it looks. Restarting BITS re-reads the persisted job list from disk — that is what event 306 records — so it does not clear the queue and will not resurrect a job whose no-progress timeout has expired. It is the right move for a wedged service, and it is precisely the wrong move for a policy problem, a proxy problem, or a caller bug, all three of which it hides.

The same logic condemns the SoftwareDistribution rename. That folder is Windows Update's download and datastore area. Renaming it does not touch the BITS queue, does not touch the Delivery Optimization cache, and does not change a single one of the policy values above — but it does throw away the update history and datastore that let you correlate a failed transfer with the update that wanted it. It is a step you take when you have a reason, not a step you take instead of finding one.

Proof it worked: the events a healthy transfer actually emits

Do not accept a progress bar as proof. A recovered transfer leaves a specific, checkable trail, and every item below is verifiable read-only.

The queue moves. Re-run the two-sample delta from step 3. A healthy job shows a rising BytesTransferred and a state of Transferring. That is one command and it is not ambiguous.

The channel logs a start and a clean stop. You want event 59 ("BITS started the ... transfer job that is associated with the ... URL"), then event 60 with status code 0x0 at Information level, then event 4 ("The transfer job is complete"). Match them by Job ID. What you do not want is another event 61 at Warning level with a non-zero status — the presence or absence of 61 for your Job ID is the cleanest pass/fail signal in the whole system.

The lab device's own numbers show what a working queue looks like at rest: 70 event-59 starts, 71 event-60 stops, 70 event-3 job creations and 71 event-4 completions across the sampled window, against four event-61 warnings. Successes outnumbering warnings by roughly eighteen to one is the shape of health. A device where 61 is climbing and 4 is flat is a device with a transfer problem, and you can measure that from a single command without touching anything.

Delivery Optimization advances too. Get-DeliveryOptimizationStatus shows TotalBytesDownloaded rising against FileSize, and where peering is working it reports a real figure in PercentPeerCaching — on the lab device, 33.68 percent for a 2.79 GB Microsoft 365 payload, meaning a third of that file came from the local network rather than the internet. That number is the only honest way to answer "is our peer caching actually doing anything", and it is worth capturing on a handful of devices before anybody redesigns a Delivery Optimization policy.

And the negative check. The job you diagnosed should now be absent from Get-BitsTransfer -AllUsers, because a job that reached ACKNOWLEDGED or CANCELLED is documented not to appear in a job enumeration. Absence here is success — but only because you already saw the event 4 that explains it. Absence without that event is the same ambiguity you started with, which is the whole reason this article reads the log and the queue together rather than either one alone.

References

Was this post helpful?
React below — no account needed
Share this post
LinkedIn X / Twitter Reddit Bluesky

More from EndpointWeekly

Windows Update
The Windows patching triage decision tree: which log, which key,…
Route each patching symptom to the one evidence source that answers it. Then learn the…
Windows Update
One read-only PowerShell collector for Windows patching failures…
Test-Path and value checks return confidently wrong patching verdicts on real devices.…
Windows Update
Delivery Optimization says it is peering and your WAN link says…
DO falls back to the CDN silently, with no error and no Event Viewer channel to read.…