v3.2.0 起只支持 .NET 8 及以上,老框架需用 v3.1.x;下载中文件先写成 .download 临时文件,完成后才改名;自定义 Accept-Encoding 为 gzip/br 时必须同时设置 AutomaticDecompression,否则存下的是压缩字节
Downloader
bezzadMIT活跃维护面向 .NET 应用的多线程分片下载库,支持异步进度回调,跨平台且稳定,适合在 C# 项目里集成文件下载能力。
Fast, cross-platform and reliable multipart downloader with asynchronous progress events for .NET applications.
dotnet add package DownloaderREADME 未说明,纯 .NET 库,可在 Windows、Linux、macOS 上运行,无需 GPU
面向 .NET 的跨平台多分片下载库,用异步进度事件解决大文件下载慢和断点续传问题
面向 .NET 应用的多线程分片下载库,支持异步进度回调,跨平台且稳定,适合在 C# 项目里集成文件下载能力。
核心能力
- 按 ChunkCount 把文件切成多片并行下载
- 下载过程中实时推送每个分片的进度与速度
- 自动续传:把元数据写入 .download 文件,下次自动恢复
- 支持限速、失败重试、指定字节区间下载
- 可下载到内存流,支持边下边播的流式读取
容易踩的坑
- v7.0.0最新正式版2026-11-07 · 2026-11-07 16:58 UTC
Progress events are now raised one call at a time, in order (#256). It has breaking changes (marked BREAKING below), so the major version goes up.
🔄 Changed
DownloadProgressChangedis raised one call at a time, in order (#256). Before, every chunk thread raised it, so with a parallel download the handler ran on several threads at once, an older total could arrive after a newer one, and Rx observers broke. Now the storage writer reports the bytes it has written, and one deliverer on the thread pool raises the event. The download never waits for the handler: a slow handler gets merged updates (ProgressedByteSizeis the sum since the last one), and the last update can arrive afterDownloadFileCompleted. A handler that throws is logged and the download goes on.ChunkDownloadProgressChangeddoes not change: it still runs on each chunk's thread, so use it if you must stop a download at an exact point.- BREAKING (behaviour): live streaming uses one chunk. With
EnableLiveStreaming = truethe download uses one connection, soReceivedBytescomes in file order, and every block reaches the handler. When the waiting blocks passMaximumMemoryBufferBytes, the download waits for the handler (0= no limit). - BREAKING:
EnableLiveStreamingcan not change while a download runs. Setting it to a new value then throws anInvalidOperationException. - BREAKING (API):
ConcurrentStream.WriteandConcurrentStream.WriteAsyncare internal now. Only the download itself writes to its storage; these methods were public only because the class is.
🐛 Fixes
- A download could hang forever when the memory buffer was full. If the writer emptied the buffer at the same moment a chunk stopped adding to it, nothing started the adding again. This happens most with a small
MaximumMemoryBufferBytes.
What's Changed
- Apply fixes from CodeFactor by @codefactor-io[bot] in https://github.com/bezzad/Downloader/pull/255
- Raise DownloadProgressChanged one call at a time (#256) by @bezzad in https://github.com/bezzad/Downloader/pull/257
Full Changelog: https://github.com/bezzad/Downloader/compare/v6.0.1...v7.0.0
- v6.0.12026-11-03 · 2026-11-03 09:51 UTC
查看更新说明
🐛 Fixes
- A pause while the download is starting is kept again. 6.0.0 made
Pause()andResume()do nothing unless the download was Running or Paused, so aPause()while the server was still asked for the size (status None or Created) was lost and the download ran on. Now only a finished download (Completed, Failed or Stopped) ignores them.
Full Changelog: https://github.com/bezzad/Downloader/compare/v6.0.0...v6.0.1
- A pause while the download is starting is kept again. 6.0.0 made
- v6.0.02026-11-02 · 2026-11-02 16:27 UTC
查看更新说明
A big safety and reliability release. It has two breaking changes (marked BREAKING below), so the major version goes up. Version 5.11.0 was never published; its fixes are part of this release.
🔒 Security
- BREAKING: server certificates are checked strictly. The library had its own certificate check that accepted expired, self-signed and wrong-host certificates, so anyone on the network could replace the file or read your credentials. Now the normal platform check is used, and a certificate error is not retried. If you must trust a private or self-signed certificate, give your own handler with
CustomHttpMessageHandlerFactory(see the README). - A server file name can no longer write outside your folder. The name from
Content-Disposition(for example../../.bashrc,/etc/xorC:\x.exe) is cleaned: only the last part is kept, and characters that are not allowed in a file name become_.filename*=UTF-8''...names are now read, andFileName=is matched without caring about case. - Credentials are not sent to another host after a redirect. The
Authorizationheader (fromRequestConfiguration.AuthorizationorCredentials) is sent only to the scheme, host and port you asked for. After a redirect to a CDN, or from https to http, it is left out.
🔄 Changed
- BREAKING: the system proxy is used when
RequestConfiguration.Proxyis null. Before, no proxy was used at all, so downloads failed on networks that need one. Now the OS proxy settings (orHTTP_PROXY/HTTPS_PROXY/NO_PROXY) are used, likeHttpClientdoes. To use no proxy, setProxy = new WebProxy().
🐛 Fixes
- A server that ignores ranges no longer breaks the file. When a server (or one of the mirror URLs) said it supports ranges but sent the whole file to a chunk request, those bytes were written in the middle of the file and the download reported Completed. Every chunk now checks the answer. On a wrong answer the download starts again once over one connection; a
RangeDownloadfails with the newInvalidRangeResponseException. - Auto-resume no longer mixes two versions of a file. The
ETagandLast-Modifiedheaders are saved with the resume state (newPackageInfo.ETag/LastModified, also onRemoteFileInfo). If the server file changed but kept its size, the download starts again instead of mixing old and new bytes. - Auto-resume reads its state correctly. The saved state now starts with its length, so a shorter state written over a longer one is read right (before, the download silently started again from 0). Old
.downloadfiles are still read. - Bytes that were not written are not counted. A write to a closed storage threw nothing and the chunk counted the bytes anyway, which could leave holes after a manual resume. Now the write throws
ObjectDisposedException, the rented buffer is returned, and the packet is not left in the queue. - Your configuration is no longer changed by a download. For a small file, a server without range support, or a range download, the library wrote
ChunkCount,ParallelCount,ParallelDownload,RangeLow/RangeHighandCustomHttpClientFactoryback into yourDownloadConfiguration, so the next download with the same object was slow. These values are now kept per download.DownloadConfiguration.Clone()now also copiesRequestConfiguration(headers and client certificates are new lists; cookies, proxy and credentials stay shared). - Long retry runs are safe. A chunk retry called itself again, so many failures in a row made a deeper and deeper call chain. It is now a loop. The read timeout that grows on every retry is capped at 60 seconds (or
BlockTimeoutif that is bigger); before, it could overflow to a negative value. ParallelDownload = falsenow really uses one connection. All chunks were started at once and only waited on a semaphore ofParallelCount, so a "serial" download opened up toParallelCountconnections. Now chun
- BREAKING: server certificates are checked strictly. The library had its own certificate check that accepted expired, self-signed and wrong-host certificates, so anyone on the network could replace the file or read your credentials. Now the normal platform check is used, and a certificate error is not retried. If you must trust a private or self-signed certificate, give your own handler with
- v5.10.22026-11-01 · 2026-11-01 06:31 UTC
查看更新说明
A small fix for pause/resume and cancel threading.
🐛 Fixes
- Resume and cancel no longer run work on the wrong thread (#251).
PauseTokenSourceand the download's completion source finished their tasks inline. SoResume()could run the paused download work on the caller's thread (for example the UI thread), and code afterawait CancelTaskAsync()could run on the download thread beforeDownloadFileCompletedwas raised. Both now useTaskCreationOptions.RunContinuationsAsynchronously.
Full Changelog: https://github.com/bezzad/Downloader/compare/v5.10.1...v5.10.2
- Resume and cancel no longer run work on the wrong thread (#251).
- v5.10.02026-10-15 · 2026-10-15 13:35 UTC
查看更新说明
Safer live settings, correct file size after a server error, and smarter splitting of the last chunks.
✨ New
- Idle connections can help finish the download. When
MinimumChunkSizeis set (greater than 0), a parallel worker that has finished its own chunk can split the largest unfinished chunk and download its second half, as long as both halves stay at leastMinimumChunkSize. With the default0nothing changes. (#245, thanks @crazysmile-PhD)
🐛 Fixes
- A server error on the first probe could give a wrong, tiny file reported as complete (#248). The headers of an error response (for example a 500 with a 41-byte XML body) were read as the file's headers, so the error body's length became the file size. Now only a success or a redirect fills the response headers, so the download really asks again and fails if it never reaches the file.
- Range support is kept after a passing server error. A 500 on the first
Range: 0-0probe no longer switches the download to one connection; the Range probe is asked again. A transient status on the probe (503, 429, 504, 408) is retried up to 3 times instead of failing the download. - Settings you change during a download are thread-safe (#247).
MaximumBytesPerSecond,ActiveChunks,BufferBlockSizeandEnableLiveStreaminguse atomic reads and writes, so a change from another thread (like a UI speed limit) is seen by the download threads at once, andPropertyChangedfires once per real change.
- Idle connections can help finish the download. When
用户点评
登录用户的真实使用记录,可按版本和硬件筛选还没有人点评。第一个说出真实体验的人最有价值。
Downloader
:rocket: Fast, cross-platform, and reliable multipart downloader in .Net :rocket:
[!TIP] Prefer a ready-to-use app over a library? Meet Downloader Desktop — a free, open-source GUI download manager for Windows · Linux · macOS, powered by this engine: multi-connection downloads, pause/resume, queues, scheduler, speed limits and more — with no dependencies to install.
Downloader is a modern, fluent, asynchronous, and portable library for .NET, built with testability in mind. It supports multipart downloads with real-time asynchronous progress events. The current v5.x line targets .NET 8, .NET 9, .NET 10, and .NET 11 (preview). (If you need .NET Standard 2.1 or older runtimes such as .NET Framework 4.6.1, use the v3.1.x line — see the note below.)
Downloader works on Windows, Linux, and macOS.
Note: Support for older versions of .NET was removed in Downloader
v3.2.0. From this version onwards, only.Net 8.0and higher versions are supported.
If you need compatibility with older .NET versions (e.g.,.NET Framework 4.6.1), use Downloaderv3.1.*.
For a complete example, see the Downloader.Sample project in this repository.
Table of Contents
- Quick Start
- Sample Console Application
- Key Features
- Installation via NuGet
- Installation via the .NET CLI
- Usage
- Fluent download builder usage
- Using a Custom HttpClient or HttpMessageHandler
- When does the Downloader fail to download in multiple chunks?
- Redirects, cookies, and protected links
- How to serialize and deserialize the downloader package
- Building a Native AOT Version
- Instructions for Contributing
- Support the Project
- License
- Contributors
Quick Start
dotnet add package Downloader
await DownloadBuilder
.New()
.WithUrl(@"https://host.com/test-file.zip")
.WithDirectory(@"C:\temp")
.Build()
.StartAsync();
Sample Console Application

Key Features
- Simple interface for download requests.
- Asynchronous, non-blocking file downloads.
- Supports all file types (e.g., images, videos, PDFs, APKs).
- Cross-platform support for files of any size.
- Real-time progress updates for each download chunk.
- Downloads files in multiple parts (parallel download).
- Resilient to client-side and server-side errors.
- Configurable
ChunkCountto control download segmentation. - Supports both in-memory and on-disk multipart downloads.
- Parallel saving of all chunks directly into one file (no extra file per chunk).
- Always downloads to a temporary file (configurable extension, default
.download), then renames to the final name on completion. - Always pre-allocates file size before download begins.
- Resume downloads manually by saving and restoring the
DownloadPackageobject. - Automatic resume: when enabled, download metadata is embedded inside the
.downloadfile — no extra files or manual serialization needed. - Provides real-time speed and progress data.
- Asynchronous pause and resume functionality.
- Download files with dynamic speed limits.
- Supports downloading to memory streams (without saving to disk).
- Supports large file downloads and live-streaming (e.g., music playback during download).
- Download a specific byte range from a large file.
- Resolve a remote file's name, size, and range support without downloading it (
RemoteFileResolver). - Lightweight, fast codebase. The only dependency is
Microsoft.Extensions.Logging.Abstractions. - Manage RAM usage during downloads.
- Supports custom
HttpClientorHttpMessageHandlerinjection for advanced scenarios (e.g.,IHttpClientFactory, HTTP caching, custom delegating handlers).
Installation via NuGet
PM> Install-Package Downloader
Installation via the .NET CLI
dotnet add package Downloader
Usage
Step 1: Create a Custom Configuration
Simple Configuration
var downloadOpt = new DownloadConfiguration()
{
// Number of file parts, default is 1
ChunkCount = 8,
// Download parts in parallel (default is false)
ParallelDownload = true
};
Complex Configuration
Note: Only include the options you need in your application.
var downloadOpt = new DownloadConfiguration()
{
// size of each read from the network (1 byte to 1MB),
// the default value is 1024 bytes
BufferBlockSize = 10240, // 10KB
// file parts to download, the default value is 1
ChunkCount = 8,
// download speed limited to MaximumBytesPerSecond,
// the default value is zero, which means unlimited
MaximumBytesPerSecond = 1024*1024*2, // 2MB/s
// the maximum number of retries for each chunk,
// the default value is int.MaxValue (retry forever)
MaxTryAgainOnFailure = 5,
// release memory buffer after each MaximumMemoryBufferBytes
MaximumMemoryBufferBytes = 1024 * 1024 * 50, // 50MB
// download parts of the file in parallel or not.
// The default value is false
ParallelDownload = true,
// number of parallel downloads.
// The default value is the same as the chunk count
ParallelCount = 4,
// timeout (millisecond) per stream block reader,
// the default value is 5000 (minimum 100)
BlockTimeout = 5000,
// timeout (millisecond) per HttpClient request,
// default value is 100 seconds
HttpClientTimeout = 100 * 1000,
// set true if you want to download just a specific
// range of bytes of a large file
RangeDownload = false,
// floor offset of download range of a large file
RangeLow = 0,
// ceiling offset of download range of a large file
RangeHigh = 0,
// clear package chunks data when download completed
// with failure, default value is false
ClearPackageOnCompletionWithFailure = true,
// the minimum size of file to chunking or download a
// file in multiple parts, the default value is 512
MinimumSizeOfChunking = 102400, // 100KB
// the minimum size of a single chunk,
// default value is 0 equal unlimited
MinimumChunkSize = 10240, // 10KB
// Get on-demand downloaded data with
// ReceivedBytes on the downloadProgressChanged event.
// Live streaming uses one chunk, and it can not be
// changed while a download runs (see Step 3)
EnableLiveStreaming = false,
// How to handle an existing filename when
// starting to download?
FileExistPolicy = FileExistPolicy.Delete,
// When enabled, the Downloader appends package
// metadata to the end of the .download file.
// On the next download attempt,
// if metadata is found in an existing .download file,
// the download resumes automatically.
EnableAutoResumeDownload = true,
// A temporary extension appended to
// the real filename while downloading.
// e.g., "file.zip" becomes "file.zip.download"
// during download. The Downloader always uses this
// extension regardless of EnableAutoResumeDownload.
// When the download completes,
// the file is renamed back to its final name.
DownloadFileExtension = ".download",
// config and customize request headers
RequestConfiguration =
{
Accept = "*/*",
CookieContainer = cookies,
// your custom headers. Note: the default list is ["Accept-Encoding: identity"];
// if you ask for gzip/br here, also set AutomaticDecompression,
// or the compressed bytes are saved as the file.
Headers = ["Accept-Encoding: identity", "X-Custom-Header: value"],
KeepAlive = true,
ProtocolVersion = HttpVersion.Version11,
// your custom user agent or
// your_app_name/app_version.
UserAgent = "Mozilla/5.0",
// without a Proxy the system proxy is used;
// new WebProxy() (no address) means no proxy at all.
Proxy = new WebProxy() {
Address = new Uri(
"http://YourProxyServer:8080"),
UseDefaultCredentials = false,
Credentials = System.Net.CredentialCache.DefaultNetworkCredentials,
BypassProxyOnLocal = true
},
Authorization = new AuthenticationHeaderValue
("Bearer", "XX_YOUR_TOKEN_XX")
}
};
Recommended setup for fast and reliable downloads
A good general-purpose starting point — parallel for speed, with retries and auto-resume for safety:
var downloadOpt = new DownloadConfiguration
{
// split into 8 parts
ChunkCount = 8,
// download chunks as parallel (fast)
ParallelDownload = true,
// cap concurrent connections to be a good citizen
ParallelCount = 4,
// retry transient network errors before giving up
MaxTryAgainOnFailure = 5,
// survive crashes/network drops with zero extra code
EnableAutoResumeDownload = true,
// cap RAM used for buffering (50MB)
MaximumMemoryBufferBytes = 50 * 1024 * 1024,
// fail early if there isn't enough disk space
CheckDiskSizeBeforeDownload = true,
// 0 = unlimited; set a value to limit speed B/s
MaximumBytesPerSecond = 0,
};
Tip: Higher
ChunkCount/ParallelCountis not always faster — many servers rate-limit or cap connections per client. Values around8chunks and4parallel connections are a safe, fast default; tune for your target hosts.
Step 2: Create the Download Service
var downloader = new DownloadService(downloadOpt);
Step 3: Handle Download Events
// Provide `FileName` and `TotalBytesToReceive` at
// the start of each download
downloader.DownloadStarted += OnDownloadStarted;
// Provide any information about chunk downloads,
// like progress percentage per chunk, speed,
// total received bytes and received bytes array
// to live streaming.
downloader.ChunkDownloadProgressChanged += OnChunkDownloadProgressChanged;
// Provide any information about download progress,
// like progress percentage of sum of chunks, total speed,
// average speed, total received bytes and
// received bytes array to live streaming.
downloader.DownloadProgressChanged += OnDownloadProgressChanged;
// Download completed event that can include errors or
// canceled or download completed successfully.
downloader.DownloadFileCompleted += OnDownloadFileCompleted;
Example handler implementations:
void OnDownloadStarted(object? sender, DownloadStartedEventArgs e)
=> Console.WriteLine($"Started '{e.FileName}' ({e.TotalBytesToReceive} bytes)");
void OnDownloadProgressChanged(object? sender, DownloadProgressChangedEventArgs e)
=> Console.WriteLine($"{e.ProgressPercentage:F1}% @ {e.BytesPerSecondSpeed:F0} B/s");
void OnDownloadFileCompleted(object? sender, System.ComponentModel.AsyncCompletedEventArgs e)
{
if (e.Cancelled)
Console.WriteLine("Download was stopped by the caller.");
else if (e.Error is not null)
Console.WriteLine($"Download FAILED: {e.Error.Message}"); // inspect/log e.Error
else
Console.WriteLine("Download completed successfully.");
}
ℹ️ How progress events are raised
ChunkDownloadProgressChangedruns on each chunk's own thread, so with a parallel download these calls can run at the same time.DownloadProgressChangedreports the bytes that are already written to the file (or memory stream). It is raised one call at a time and in order, so the total never goes down, and it is safe for Rx (OnNextis never called twice at once). The download never waits for this handler: when the handler is slow, several updates are merged into one, andProgressedByteSizeis the sum of the bytes since the last event.- The handler runs on a thread-pool thread, so keep it short. An exception it throws is logged, and the download goes on.
DownloadFileCompleteddoes not wait for the progress handler, so the last progress event (100%) can arrive a little after it.- Live streaming (
EnableLiveStreaming = true): the download uses one chunk, soReceivedBytescomes in file order, and every block reaches the handler (nothing is merged). If the handler is slower than the network, the blocks wait in memory; when they passMaximumMemoryBufferBytes, the download waits for the handler (0= no limit). The last block is the one withReceivedBytesSize == TotalBytesToReceive. SetEnableLiveStreamingbefore the download starts; changing it while a download runs throws anInvalidOperationException.
⚠️ Important: how failures and cancellations are reported
DownloadFileTaskAsync(...)/StartAsync(...)do not throw when a download fails or is cancelled. The awaited call completes normally, and the outcome is delivered through theDownloadFileCompletedevent:
- Success →
e.Cancelled == falseande.Error == null- Failure →
e.Errorcontains the exception (e.g. a network error, or anIncompleteDownloadExceptionif the server ended the stream early)- Stopped by you →
e.Cancelled == true(also whenFileExistPolicy.IgnoreDownloadskips an existing file).Pause()does not raise this event; the download is still running.Always subscribe to
DownloadFileCompletedand checke.Errorbefore treating the file as complete — relying only on theawaitreturning will silently miss failed downloads. You can also inspectdownloader.Package.Status(Completed/Stopped/Failed).
Step 4: Start the Download
string file = @"Your_Path\fileName.zip";
string url = @"https://file-examples.com/fileName.zip";
await downloader.DownloadFileTaskAsync(url, file);
Step 4b: Start the download without a file name
DirectoryInfo path = new DirectoryInfo("Your_Path");
string url = @"https://file-examples.com/fileName.zip";
// download into "Your_Path\fileName.zip"
await downloader.DownloadFileTaskAsync(url, path);
The file name comes from the server (Content-Disposition, filename*= first) or from the URL.
It is cleaned before use: only the last path part is kept and invalid characters become _, so a
server can never write a file outside path.
Step 4c: Download in MemoryStream
// After the download completes, you get a MemoryStream
Stream destinationStream = await downloader.DownloadFileTaskAsync(url);
How to get the file name and size without downloading
Sometimes you need a remote file's name and size before (or without ever) downloading it — for example to populate a list/grid of queued downloads with their names and sizes while they wait for a slot, instead of starting and immediately stopping a real download just to read the headers.
Use RemoteFileResolver. It performs a single lightweight header probe (a Range: 0-0 GET that
follows redirects) and resolves the file name exactly the way the downloader does internally — from
the Content-Disposition header, falling back to the URL path, and finally to a generated GUID —
plus the size (Content-Range → Content-Length) and whether the server supports ranged
(resumable) downloads.
// Just the file name
// (resilient: falls back to the URL-derived name
// on a network/server error)
string fileName = await RemoteFileResolver.GetFileNameAsync(url);
// Full metadata in one probe:
RemoteFileInfo info = await RemoteFileResolver.GetFileInfoAsync(url);
Console.WriteLine($"{info.FileName} — {info.FileSize} bytes, range: {info.SupportsRange}");
// info.Address is the final URL after any redirects.
Both methods accept an optional DownloadConfiguration (to reuse your headers, proxy, credentials,
cookies, redirect policy, …) and a CancellationToken. Each call owns and disposes its own client,
so when resolving many URLs at once add your own concurrency limiting / timeouts.
If you already hold a download service, the same lookup is available on it (using that service's configuration, without disturbing any download in progress):
RemoteFileInfo info = await downloader.GetFileInfoAsync(url);
Note:
FileSizeis-1when the server does not advertise a length.GetFileNameAsyncand the service/GetFileInfoAsyncpreviews are best-effort and won't throw on a server that hides its size — you still get a usable name.
How to pause and resume downloads quickly
When you want to resume a download quickly after pausing for a few seconds, call the Pause function of the downloader service. This way, the streams stay alive and are only suspended by a lock, to be released and resumed whenever you want.
// Pause the download
downloader.Pause();
// Resume the download
downloader.Resume();
How to stop and resume downloads (manual approach)
The DownloadService class has a property called Package that holds a live snapshot of the download state (chunk positions, URL, file path, etc.). While the download is in progress, this object is updated continuously.
To stop and later resume a download manually, you are responsible for keeping the Package object yourself — either in memory or serialized to disk. The Downloader does not store it for you in this approach.
// 1. Keep a reference to the package before or after stopping:
DownloadPackage pack = downloader.Package;
Stop or cancel the download:
// Fire-and-forget cancellation (returns void):
downloader.CancelAsync();
// Or await until the download has actually stopped before reading the package:
await downloader.CancelTaskAsync();
Resume later — even after restarting the application:
// Pass the same (or deserialized) package to resume from the last position:
await downloader.DownloadFileTaskAsync(pack);
The Package object is lightweight — it contains only the URL, file path, and the position of each chunk (not the downloaded bytes). You can serialize it to JSON or binary (see Serialization section) and restore it at any time.
For more details see the StopResumeDownloadTest method.
Note: If the server does not support HTTP range requests, the download cannot be resumed and will restart from the beginning.
How to automatically resume downloads (recommended)
If you don't want to manage DownloadPackage serialization yourself, enable EnableAutoResumeDownload. The Downloader will then handle everything automatically — no manual serialization or package management is needed.
Configuration:
var downloadOpt = new DownloadConfiguration()
{
EnableAutoResumeDownload = true,
// optional, the default extension is '.download'
DownloadFileExtension = ".download"
};
var downloader = new DownloadService(downloadOpt);
How .download files work:
The Downloader always uses a temporary file with the .download extension (configurable via DownloadFileExtension) — this behavior is independent of EnableAutoResumeDownload. For example, downloading report.pdf creates report.pdf.download on disk. While the download is in progress, only the .download file exists — the user does not see the final filename. When the download completes successfully, the file is renamed to report.pdf.
What EnableAutoResumeDownload adds:
When this option is true, the Downloader appends package metadata to the end of the .download file, immediately after the file data. This metadata enables automatic resume without any work from the caller.
The file structure during download looks like this:
report.pdf.download:
_____________________________________________________
| | |
|<------------ File Data ----------->|<-- Metadata -->|
|____________________________________|________________|
The metadata is an 8-byte length and then the JSON, so an older and longer metadata that is still on disk behind it is ignored. Files from older versions (JSON only) are still read.
- File Data region: The actual file content. The file size for this region is pre-allocated at the start.
- Metadata region: The
DownloadPackagestate (includes TotalFileSize and Chunks) is serialized to JSON and then written as binary at the end of the same file.
The metadata grows as the download progresses (more chunk positions are recorded), so the on-disk file size is always TotalFileSize + current metadata size. The metadata only grows — it never shrinks — so no padding or extra management is needed.
On interruption:
If the download is interrupted (crash, network failure, app restart), the .download file remains on disk with both the partial file data and the latest metadata embedded at the end.
On resume:
When you call DownloadFileTaskAsync for the same file again, the Downloader:
- Detects the existing
.downloadfile - Reads the metadata from the end of the file to restore the
DownloadPackagestate - Verifies the server still supports range requests, that the file size has not changed, and that the
ETagandLast-Modifiedheaders (when the server sends them) are the same as before - Resumes downloading from where each chunk left off
- Falls back to a fresh download if any validation fails
On completion:
When the download finishes successfully:
- The file stream is truncated to
TotalFileSizeusingSetLength(TotalFileSize), which removes the appended metadata - The file is renamed from
report.pdf.downloadtoreport.pdf
The result is a clean final file with no metadata artifacts.
Note: If the server does not support range requests or the remote file size has changed, the Downloader will discard the partial data and start a fresh download.
Fluent download builder usage
For easy and fluent use of the downloader, you can use the DownloadBuilder class. Consider the following examples:
Simple usage:
await DownloadBuilder
.New()
.WithUrl(@"https://host.com/test-file.zip")
.WithDirectory(@"C:\temp")
.Build()
.StartAsync();
Complex usage:
IDownload download = DownloadBuilder
.New()
.WithUrl(@"https://host.com/test-file.zip")
.WithDirectory(@"C:\temp")
.WithFileName("test-file.zip")
.WithConfiguration(new DownloadConfiguration())
.Build();
download.DownloadProgressChanged += DownloadProgressChanged;
download.DownloadFileCompleted += DownloadFileCompleted;
download.DownloadStarted += DownloadStarted;
download.ChunkDownloadProgressChanged += ChunkDownloadProgressChanged;
await download.StartAsync();
download.Stop(); // cancel current download
Resume the existing download package:
await DownloadBuilder.New().Build(package).StartAsync();
Resume the existing download package with a new configuration:
await DownloadBuilder.New().Build(package, config).StartAsync();
var download = DownloadBuilder
.New()
.WithUrl(url)
.WithFileLocation(path)
.Build();
download.DownloadProgressChanged += (_, _) => {
// pause current download quickly
download.Pause();
// continue current download quickly
download.Resume();
};
await download.StartAsync().ConfigureAwait(false);
Using a Custom HttpClient or HttpMessageHandler
Some scenarios require using a custom HttpClient or HttpMessageHandler — for example, to reuse the connection pool from IHttpClientFactory, add HTTP caching via a DelegatingHandler, or apply custom authentication logic.
The Downloader provides two delegate properties on DownloadConfiguration for this purpose:
Option 1: Provide a fully custom HttpClient
Use CustomHttpClientFactory when you want full control over the HttpClient instance. The Downloader will skip all internal handler and header configuration and use the returned client directly.
var downloadOpt = new DownloadConfiguration()
{
ChunkCount = 8,
ParallelDownload = true,
CustomHttpClientFactory = () => {
// Example: use IHttpClientFactory
return httpClientFactory.CreateClient("MyDownloader");
}
};
var downloader = new DownloadService(downloadOpt);
await downloader.DownloadFileTaskAsync(url, filePath);
Option 2: Provide a custom HttpMessageHandler
Use CustomHttpMessageHandlerFactory when you want to customize only the handler (e.g., for caching or custom SSL), while letting the Downloader still configure default request headers and timeout on the HttpClient.
var downloadOpt = new DownloadConfiguration()
{
ChunkCount = 8,
ParallelDownload = true,
CustomHttpMessageHandlerFactory = () => {
return new SocketsHttpHandler {
MaxConnectionsPerServer = 500,
PooledConnectionLifetime = TimeSpan.FromMinutes(10)
};
}
};
var downloader = new DownloadService(downloadOpt);
await downloader.DownloadFileTaskAsync(url, filePath);
Using the fluent builder API
Both options are also available through the DownloadBuilder:
// With a custom HttpClient
await DownloadBuilder.New()
.WithUrl(url)
.WithDirectory(@"C:\temp")
.WithHttpClient(() => httpClientFactory.CreateClient("MyDownloader"))
.Build()
.StartAsync();
// With a custom HttpMessageHandler
await DownloadBuilder.New()
.WithUrl(url)
.WithDirectory(@"C:\temp")
.WithHttpMessageHandler(() => new SocketsHttpHandler {
MaxConnectionsPerServer = 500,
PooledConnectionLifetime = TimeSpan.FromMinutes(10)
})
.Build()
.StartAsync();
Note: If both
CustomHttpClientFactoryandCustomHttpMessageHandlerFactoryare set,CustomHttpClientFactorytakes precedence andCustomHttpMessageHandlerFactoryis ignored.
Private or self-signed certificates
Server certificates are checked by the platform. A download from a server with an expired, self-signed or wrong-host certificate fails. If you trust such a server (for example on your own network), give your own handler and check the certificate yourself:
var config = new DownloadConfiguration {
CustomHttpMessageHandlerFactory = () => new SocketsHttpHandler {
SslOptions = {
// Only accept the one certificate you trust, never "return true" for all.
RemoteCertificateValidationCallback = (_, cert, _, errors) =>
errors == SslPolicyErrors.None || cert?.GetCertHashString() == "YOUR-CERT-THUMBPRINT"
}
}
};
When does the Downloader fail to download in multiple chunks?
Content-Length
If your server does not provide the file size in the response header (Content-Length),
the Downloader cannot split the file into multiple parts and continues its work with one chunk.
Accept-Ranges
If the server returns Accept-Ranges: none in the response header, that means the server does not support downloading in range, so
the Downloader cannot use multiple chunks and continues its work with a single chunk.
Content-Range
At first, the Downloader sends a GET request to the server to fetch the file's size in the range.
If the server does not provide Content-Range in the header, that means the server does not support downloading in range.
Therefore, the Downloader has to continue its work with one chunk.
Range answers
Some servers (or a CDN node, or a mirror) say they support ranges but then send the whole file with
200 OK. Every chunk checks the answer: it must be 206 from the asked byte (or 200 when the chunk
starts at byte 0). If not, the Downloader starts again once over one connection, so the file is never
broken. For a RangeDownload this is not possible, so the download fails with
InvalidRangeResponseException.
Content-Encoding (compressed responses)
If the server returns a compressed response (a Content-Encoding header such as gzip,
deflate, or br), the Downloader falls back to a single, non-ranged chunk and treats the size as
unknown. A Range request addresses byte offsets in the compressed stream,
which do not map to offsets in the decompressed bytes the client ultimately delivers — so splitting
a compressed representation into ranges would corrupt the output. Downloading as one chunk keeps
decompression consistent. By default the Downloader sends Accept-Encoding: identity, so servers
normally do not compress. If you ask for compression (for example with a custom Accept-Encoding
header), also set RequestConfiguration.AutomaticDecompression; otherwise the compressed bytes are
saved as the file.
Redirects, cookies, and protected links
The Downloader follows HTTP redirects automatically (AllowAutoRedirect = true, up to
MaximumAutomaticRedirections = 50), including 301, 302, 303, 307, and 308. It also
follows same-URL "challenge" redirects — for example a CDN that answers with a 307 whose
Location points back to the same URL and a Set-Cookie, expecting the client to retry carrying
that cookie.
To make those challenges resolvable, RequestConfiguration.CookieContainer now defaults to a new
CookieContainer, so cookies issued mid-redirect are stored and replayed on the follow-up
request. Set it to null to disable cookie handling entirely, or supply your own container with
pre-seeded cookies:
var downloadOpt = new DownloadConfiguration
{
RequestConfiguration =
{
CookieContainer = myCookieContainer, // or null to disable cookies
}
};
Note: No HTTP client can pass a JavaScript-based bot challenge (the cookie value must be computed by a browser), nor revive an expired signed link — such URLs typically return a small HTML "expired/blocked" page with status
200. If a download produces a tiny unexpected file, inspect the real response first (e.g.curl -I <url>) before assuming a Downloader bug.
How to serialize and deserialize the downloader package
Tip: If you use
EnableAutoResumeDownload = true, you do not need to serialize the package yourself — the Downloader handles it automatically by embedding metadata in the.downloadfile. The section below is only relevant if you use the manual resume approach.
The DownloadPackage object holds the download state (URL, file path, chunk positions). You can serialize it to JSON or binary so that you can restore and resume a stopped download later — even after the application restarts.
JSON Serialization
// Serialize
var packageJson = JsonConvert.SerializeObject(package);
// Deserialize
var restoredPack = JsonConvert.DeserializeObject<DownloadPackage>(packageJson);
// Resume
await downloader.DownloadFileTaskAsync(restoredPack);
For more details see the PackageSerializationTest method.
Binary Serialization
To save the package as a binary file, serialize it to JSON first and then write it with BinaryWriter.
NOTE: Do not use BinaryFormatter — it is deprecated and insecure. Reference
🚀 Building a Native AOT Version
The library is Native AOT compatible (IsAotCompatible, no reflection-based serialization), so you can use it in an app that you publish with Native AOT. Follow these steps to build a native AOT version of the library itself.
Prerequisites
- .NET 8.0 SDK or later.
- A supported platform (Windows, Linux, or macOS).
Build Instructions
1. Clone the Repository
git clone https://github.com/bezzad/downloader.git
cd downloader
2. Build the Native AOT Executable
Run the following command to compile the project for your target platform:
Windows (x64):
dotnet publish src/Downloader/Downloader.csproj -r win-x64 -f net8.0 -c Release
Linux (x64):
dotnet publish src/Downloader/Downloader.csproj -r linux-x64 -f net8.0 -c Release
macOS (x64):
dotnet publish src/Downloader/Downloader.csproj -r osx-x64 -f net8.0 -c Release
3. Find the Output
The output will be located in:
src/Downloader/bin/Release/net8.0/<RUNTIME_IDENTIFIER>/publish/
Example for Windows:
src/Downloader/bin/Release/net8.0/win-x64/publish/
Instructions for Contributing
Contributions are welcome — feel free to make changes and open a Pull Request against the develop branch.
You can use either the latest version of Visual Studio or Visual Studio Code and .NET CLI for Windows, Mac and Linux.
For GitHub workflow, check out our Git workflow below this paragraph. We are following the excellent GitHub Flow process, and would like to make sure you have all the information needed to be a world-class contributor!
Git Workflow
The general process for working with Downloader is:
- Fork on GitHub
- Make sure your line endings are correctly configured and fix your line endings!
- Clone your fork locally
- Configure the upstream repo (
git remote add upstream https://github.com/bezzad/downloader.git) - Switch to the latest development branch (
git checkout develop) - Create a local branch from that (
git checkout -b myBranch). - Work on your feature
- Rebase if required
- Push the branch up to GitHub (
git push origin myBranch) - Send a Pull Request on GitHub - the PR should target (have as a base branch) the
developbranch rather thanmaster.
We accept pull requests from the community. But you should never work on a clone of master, and you should never send a pull request from master - always from a branch. Please be sure to branch from the head of the develop branch (rather than master) when developing contributions.
You can run tests with the Docker Compose file with the following command
docker compose -p downloader up
(Older Docker installs use docker-compose instead.)
Or with docker file
docker build -f ./dockerfile -t downloader-linux .docker run --name downloader-linux-container -d downloader-linux --env=ASPNETCORE_ENVIRONMENT=Development .
Or run the following command to call docker directly
docker run --rm -v ${pwd}:/app --env=ASPNETCORE_ENVIRONMENT=Development -w /app/src mcr.microsoft.com/dotnet/sdk:10.0 dotnet test -p:TargetFrameworks=net10.0 --logger:trx
Support the Project
I've spent a lot of time creating this project.
If you like my work, please give it a ⭐ — thanks! ❤️
Want to support the project?
You can make a donation in any of the following ways:
License
Licensed under the terms of the MIT License
Contributors
Thanks go to these wonderful people (List made with contrib. rocks):
Top Dependent Repositories
A few of the public projects that depend on Downloader, sorted by stars. See the full list or browse all of them on GitHub Dependents.
| Repository | Stars |
|---|---|
| 2dust / v2rayN | 119666 |
| lively-community / lively | 19980 |
| luolangaga / tubatools | 5966 |
| PCL-Community / PCL-CE | 5023 |
| goatcorp / FFXIVQuickLauncher | 3195 |
| ClassIsland / ClassIsland | 2896 |
| Paving-Base / APK-Installer | 1712 |
| rogerfar / rdt-client | 1600 |
| Taiizor / Sucrose | 1583 |
| CHKZL / DDTV | 1496 |
| jxlpzqc / TMSpeech | 1495 |
| shuyu-labs / AntSK | 1329 |
| Otiel / BandcampDownloader | 1243 |
| Reaparr / Reaparr | 699 |
| crazysmile-PhD / downkyicore | 589 |
| chickensoft-games / GodotEnv | 586 |
| anobaka / Bakabase | 567 |
| K12f / BlueCatKoKo | 497 |
| Euterpe-org / App | 492 |
| HandyOrg / HandyWinGet | 396 |