A zero-dependency parallel HTTP Range downloader that runs unchanged in browsers, Chrome MV3 extensions and Node.js 18+.
import { download } from './src/range-downloader.js';
const { blob, parallel } = await download('https://example.com/large.zip', {
connections: 8,
onProgress: ({ percent, megabytesPerSecond }) =>
console.log(`${percent}% at ${megabytesPerSecond.toFixed(2)} MB/s`)
});A single HTTP connection is frequently not the bottleneck the network is. Many origins shape throughput per connection, so one stream sits far below the available line speed while eight parallel streams saturate it. The fix is well known — request byte ranges concurrently and reassemble them — but the details are where downloads quietly corrupt themselves.
This library exists because those details are worth writing down once:
- A server can answer a Range request with
200and the whole body instead of206and the slice. Splicing that into the output shifts every later byte, and the file only breaks much later, inside whatever reads it. - A chunk can come back short. Accepting it produces a file that looks complete and is not.
- Credentials matter. A request that needs cookies but is sent without them gets a
403, and a probe that reports "size unknown" hides the real reason.
Each of those is handled explicitly, and each has a test.
- Parallel byte-range downloads through a bounded worker pool — the connection count is a ceiling, not a thread-per-chunk explosion.
- Automatic fallback. If the server does not honour Range, the download still completes in a single stream instead of failing.
- Byte-exact verification. Every chunk is checked against its expected length; the assembled result is checked against the advertised total.
- Retries with backoff, with a separate budget for servers that ignore Range so they cannot exhaust the retries meant for real network errors.
- Progress with smoothed speed and ETA.
AbortSignalsupport throughout, including the backoff sleeps.- Diagnosable failures. Errors carry the probe transcript (
error.attempts), so "download failed" becomes a list of what was tried and what each attempt answered. - Zero dependencies. Only
fetch,BlobandAbortSignal.
No build step and no package manager required — the module is plain ESM.
git clone https://github.com/tafirnat/parallel-range-downloader.git
cd parallel-range-downloader
npm test # runs the suite against local HTTP serversTo use it in your own project, copy src/range-downloader.js in, or add this repository as a dependency.
node examples/node-cli/download.js https://example.com/large.zip out.zip --connections 8[########################################] 100% 512.0 MB/512.0 MB 11.83 MB/s ETA 00:00
Saved out.zip (512.0 MB) in 43.2s via 8 parallel connections.
import { download } from './range-downloader.js';
const controller = new AbortController();
document.querySelector('#cancel').onclick = () => controller.abort();
const { blob } = await download(url, {
connections: 8,
chunkSize: 10 * 1024 * 1024,
signal: controller.signal,
onProgress: ({ percent }) => (progressBar.style.width = `${percent}%`)
});
const objectUrl = URL.createObjectURL(blob);
// ... hand it to a download, then always:
URL.revokeObjectURL(objectUrl);Probes the URL, then downloads in parallel when the server allows it and in a single stream when it does not. This is the entry point most callers want.
Returns { blob, size, parallel, attempts }, where parallel tells you which path was taken.
Asks how large the file is and whether ranges are honoured, without downloading it.
Returns { size, rangeSupported, init, attempts }. A size of 0 means the server could not be read at all — attempts says why.
A one-byte Range GET is used rather than HEAD, because many origins answer HEAD differently from GET (or reject it), and "will you serve ranges" is only trustworthy when a range was actually requested. HEAD is kept as a fallback.
The engine itself, for when you already know the size. Requires options.size. Returns a Blob.
| Option | Default | Applies to | Meaning |
|---|---|---|---|
size |
— | downloadParallel |
Total bytes. Required. |
chunkSize |
10 MiB |
download, parallel | Bytes per range request. |
connections |
8 |
download, parallel | Maximum concurrent requests. |
retries |
4 |
download, parallel | Retries per chunk before failing. |
onProgress |
— | download, parallel | Called after each completed chunk. |
fetchInit |
{} |
all | Merged into every fetch (headers, mode, ...). |
credentialModes |
include, then omit | probe, download | Credentials modes to try, in order. |
signal |
— | all | AbortSignal. |
{
downloadedBytes, totalBytes, percent,
bytesPerSecond, megabytesPerSecond, etaSeconds,
completedChunks, totalChunks
}Speed is smoothed over 500 ms windows; raw per-chunk deltas jump far too much to display.
Thrown for every failure originating in this module. Carries cause where one exists and attempts — the probe transcript — where the failure was a probe failure.
- Probe. A one-byte Range
GETestablishes the total size, whether206is honoured, and which credentials mode the server answers. That mode is then reused for every chunk request: guessing wrong is the difference between a full-speed download and a silent403. - Pool.
min(connections, chunkCount)workers pull chunk indices off a shared counter. Each fetches its range, validates the length and stores the buffer at its index, so ordering never depends on completion order. - Verify. Before assembly, every slot must be filled; after assembly, the
Blobsize must equal the advertised total. Either check failing raises rather than returning a plausible-looking file. - Release.
Blobconstruction copies the data, so the source buffers are dropped immediately — this halves peak memory for the rest of the run. On the failure path they are dropped too: a failed 4 GB download must not leave 4 GB pinned behind a rejected promise.
examples/chrome-mv3/ is a working extension showing the architecture that makes long downloads possible under Manifest V3.
An MV3 service worker is terminated after roughly 30 seconds of inactivity — far shorter than a large download, and it takes the in-flight buffers with it. An offscreen document is a normal DOM context that stays alive as long as the extension keeps it open, so the download, the chunk buffers and the Blob assembly all live there. The service worker is reduced to routing messages and calling chrome.downloads.
The object-URL lifetime is the other half of the problem. The example revokes each URL only once chrome.downloads.onChanged reports the save has finished: revoking earlier cancels the save, and never revoking keeps the entire file in memory for the life of the document.
The example imports its own copy of the engine, because an extension cannot import from outside its root. Run npm run sync:example after changing src/.
Worth knowing before you adopt it:
- The whole file is buffered in memory before it is handed back. That is fine for a few hundred megabytes and wrong for tens of gigabytes. Streaming each chunk to disk would remove the ceiling at the cost of a much larger API.
- No resume across sessions. Retries recover a failed chunk within a run; nothing is persisted if the process dies.
- Parallelism helps against per-connection shaping, not against a genuinely saturated link. If one connection already fills the pipe, eight will not make it wider.
credentialsis a browser concept. In Node it has no effect, and the first credentials mode simply wins.
npm testSeven tests run against throwaway local HTTP servers, covering the well-behaved path and the failure modes that matter: an origin that ignores Range, an origin that returns short chunks, injected 503s, and mid-flight abort. Correct reassembly is asserted by SHA-256 against the source payload, not by size alone.
MIT — see LICENSE.