react-chunked-upload
v0.2.0
Published
A lightweight React hook for chunked large-file uploads with pause, resume, retry, and progress support.
Maintainers
Readme
react-chunked-upload
A lightweight React hook for chunked large-file uploads with pause, resume, cancel, automatic retries, timeouts, progress, dynamic headers, and multipart fields.
npm | GitHub | Report a bug | Request a feature
- Small, headless React hook with no runtime dependencies
- Sequential chunk uploads using the browser
FileandBlobAPIs - Pause, resume, cancel, automatic retry with backoff, and byte-based progress
- Static or per-request async headers for expiring credentials
- Bring your own upload endpoint, authentication, storage, and UI
Installation
npm install react-chunked-uploadExamples
- React demo - interactive Vite app with a built-in mock upload endpoint
- Express server - receives, stores, and merges uploaded chunks
When to use it
Use react-chunked-upload when you need a small, UI-agnostic React file upload hook and control your own backend. It fits large video, archive, dataset, and document uploads where retrying a failed chunk is preferable to restarting the entire file.
For uploads that must resume after a browser refresh or across devices, use a persistent upload protocol such as tus instead. This package currently keeps upload state in memory for the active page session.
Why chunk files?
When uploading large files (e.g., 5GB videos or huge CSV datasets) in a traditional single-request manner, a network hiccup or timeout can cause the entire upload to fail, resulting in poor user experience and wasted bandwidth.
By splitting the file into small chunks (e.g., 5MB) on the client side using the HTML5 File and Blob APIs, react-chunked-upload provides:
- Resiliency: A failed chunk can be retried without restarting completed chunks.
- Control: You can pause and resume the upload at any time.
- Feedback: Byte-based progress after each completed chunk.
- Memory Efficiency: The browser doesn't need to load the entire file into memory at once.
Usage
import React, { useState } from 'react';
import { useChunkedUpload } from 'react-chunked-upload';
function App() {
const [file, setFile] = useState<File | null>(null);
const {
startUpload,
pauseUpload,
resumeUpload,
retryUpload,
cancelUpload,
progress,
isUploading,
isPaused,
isError,
isSuccess
} = useChunkedUpload({
chunkSize: 1024 * 1024 * 5, // 5MB chunks
uploadUrl: 'https://your-api.com/upload-chunk',
retries: 3, // auto-retry each chunk up to 3 times with backoff
timeout: 30_000, // abort a chunk request that hangs for 30s
// Static headers, or an async function evaluated before every chunk
// request — handy when tokens can expire during a long upload.
headers: async () => ({
Authorization: `Bearer ${await getFreshToken()}`,
}),
fields: {
folderId: 'invoices',
},
onChunkStart: (chunkIndex) => console.log(`Chunk ${chunkIndex} started`),
onChunkSuccess: (chunkIndex) => console.log(`Chunk ${chunkIndex} uploaded`),
onChunkError: (chunkIndex, err) => console.error(`Chunk ${chunkIndex} failed`, err),
onSuccess: (response) => console.log('Upload complete!', response),
onError: (err) => console.error('Upload failed', err),
onProgress: (p) => console.log(`Progress: ${p}%`)
});
return (
<div>
<input type="file" onChange={e => setFile(e.target.files?.[0] || null)} />
{!isUploading && !isPaused && (
<button onClick={() => file && startUpload(file)}>Start</button>
)}
{isUploading && <button onClick={pauseUpload}>Pause</button>}
{isPaused && <button onClick={resumeUpload}>Resume</button>}
{isError && <button onClick={retryUpload}>Retry failed chunk</button>}
{(isUploading || isPaused) && <button onClick={cancelUpload}>Cancel</button>}
<div>Progress: {progress}%</div>
{isSuccess && <div>Upload Successful! 🎉</div>}
{isError && <div>Error uploading file.</div>}
</div>
);
}API Reference
Options
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| uploadUrl | string | Required | Endpoint that receives each chunk as multipart form data. |
| chunkSize | number | 5 * 1024 * 1024 | Chunk size in bytes. Must be greater than 0. |
| headers | HeadersInit \| () => HeadersInit \| Promise<HeadersInit> | undefined | Headers sent with every chunk request. A function is evaluated before every chunk attempt, so expiring tokens can be refreshed mid-upload. Do not set Content-Type manually when using FormData. |
| fields | Record<string, string \| Blob> | undefined | Extra multipart fields appended to every chunk request. |
| retries | number | 0 | Automatic retry attempts per chunk after the first failure. Only network errors, timeouts, HTTP 5xx, 408, and 429 are retried; other 4xx responses fail immediately. |
| retryDelay | number \| (attempt: number, error: Error) => number | Exponential backoff | Delay in ms before retry attempt N (1-based). Defaults to 500ms doubling per attempt, capped at 10s. |
| timeout | number | undefined | Per-attempt timeout in ms. A timed-out chunk request is aborted and counts as a retryable failure. |
| onProgress | (progress: number) => void | undefined | Called after each completed chunk with byte-based progress from 0 to 100. |
| onChunkStart | (chunkIndex: number) => void | undefined | Called before each chunk request attempt starts (once per retry attempt). |
| onChunkSuccess | (chunkIndex: number, response: Response) => void | undefined | Called after each chunk request succeeds. Receives a cloned response. |
| onChunkError | (chunkIndex: number, error: Error) => void | undefined | Called for each failed chunk request attempt, including attempts that will be retried. |
| onSuccess | (response: Response) => void | undefined | Called after the final chunk succeeds. Receives the final HTTP Response. |
| onError | (error: Error) => void | undefined | Called when validation fails or a chunk exhausts its retries. Chunk failures surface as ChunkUploadError with chunkIndex and status (when an HTTP response was received). |
Return value
| Value | Type | Description |
| --- | --- | --- |
| startUpload | (file: File) => void | Starts a new upload session for the selected file. |
| pauseUpload | () => void | Aborts the in-flight request and pauses before the next chunk. |
| resumeUpload | () => void | Continues from the last completed chunk. |
| retryUpload | () => void | Retries from the failed chunk. Currently equivalent to resumeUpload. |
| cancelUpload | () => void | Aborts the in-flight request, discards the session, and resets all state. A canceled upload cannot be resumed. |
| progress | number | Upload progress from 0 to 100. |
| isUploading | boolean | true while a chunk request is active. |
| isPaused | boolean | true after pausing an active upload. |
| isError | boolean | true after validation or request failure. |
| isSuccess | boolean | true after the final chunk completes successfully. |
Backend Implementation
Your backend needs to handle the multipart form data sent by the hook. The hook sends one POST request per chunk to uploadUrl.
Multipart fields
| Field | Description |
| --- | --- |
| file | The binary chunk data. |
| filename | The original file name. |
| uploadId | A generated ID for the current upload attempt. Use this to isolate concurrent uploads. |
| chunkIndex | The current chunk number, starting at 0. |
| totalChunks | The total number of chunks for the file. |
Any fields values you provide are appended to the same multipart request, so your backend can receive project-specific metadata such as folderId or userId alongside each chunk.
The endpoint should store each chunk by uploadId and chunkIndex. When every chunk from 0 to totalChunks - 1 has been stored, merge/finalize the file before returning a successful response. Prefer checking that all chunks exist over reacting to chunkIndex === totalChunks - 1: the hook may re-send a chunk, so the final index can arrive more than once.
Idempotency requirement
Your endpoint must treat (uploadId, chunkIndex) as an idempotent key: receiving the same chunk twice must be safe and must not duplicate data. If pauseUpload() is called while a chunk response is in flight, the hook does not record that chunk as completed and re-sends it on resume. In that case onChunkSuccess also fires more than once for the same chunk index. Storing each chunk at a path derived from uploadId and chunkIndex (overwriting on re-receive), as the Express example does, satisfies this naturally.
Express example
See examples/express-server for a runnable Express server that stores chunks and merges the final file.
import express from 'express';
import multer from 'multer';
import { mkdir, readdir, rename } from 'node:fs/promises';
import path from 'node:path';
const app = express();
const upload = multer({ dest: 'tmp/chunks' });
app.post('/upload-chunk', upload.single('file'), async (req, res) => {
const { filename, uploadId, chunkIndex, totalChunks } = req.body;
if (!req.file || !filename || !uploadId || chunkIndex == null || !totalChunks) {
return res.status(400).json({ message: 'Missing chunk upload fields' });
}
const uploadDir = path.join('tmp/uploads', uploadId);
await mkdir(uploadDir, { recursive: true });
// Overwriting on re-receive keeps (uploadId, chunkIndex) idempotent.
const chunkPath = path.join(uploadDir, String(chunkIndex));
await rename(req.file.path, chunkPath);
const received = await readdir(uploadDir);
const hasAllChunks = received.length === Number(totalChunks);
if (hasAllChunks) {
// Merge chunks 0..totalChunks - 1 into the final file here.
// Only send a 2xx response after the merge/finalization succeeds.
}
return res.status(200).json({ uploadId, filename, chunkIndex, totalChunks });
});Behavior Notes
- Uploads are sequential: the next chunk starts after the previous chunk succeeds.
- Progress is updated after each completed chunk, not continuously while a chunk is streaming.
- Pausing aborts the active request and resumes from the last completed chunk. A chunk whose response was in flight when you paused is re-sent on resume, so servers must treat
(uploadId, chunkIndex)idempotently (see Idempotency requirement above). - Automatic retries are opt-in (
retriesdefaults to0). Retryable failures are network errors, timeouts, HTTP 5xx, 408, and 429; other 4xx responses fail immediately.onChunkErrorfires for every failed attempt;onErrorfires once when a chunk gives up. timeoutaborts a hung chunk request and treats it as a retryable failure. Pause, cancel, and unmount aborts are never retried.- A
headersfunction is evaluated before every chunk attempt; a staticheadersvalue is snapshotted when the upload starts. - Calling
startUploadwith invalid options reports an error but leaves a paused session resumable. - This package does not persist upload state across browser refreshes yet.
- This package does not merge chunks on the server; your backend owns storage and finalization.
- Custom
headersare sent with every chunk request, butContent-Typeshould be left to the browser when usingFormData.
Comparison
| Package | Best fit | Notes |
| --- | --- | --- |
| react-chunked-upload | A small React hook for sequential chunk uploads with pause, resume, retry, headers, and multipart fields. | Bring your own backend merge logic. |
| @rpldy/chunked-uploady | A fuller upload framework with Uploady ecosystem integrations. | More features and package surface than a single hook. |
| @rpldy/tus-sender | Apps that use the tus resumable upload protocol. | Requires a tus-compatible backend. |
| rc-upload | General-purpose upload UI plumbing. | Not focused on chunk merge workflows by default. |
| Uploadcare | Managed upload, storage, and CDN workflows. | External service instead of a small client-side library. |
Community
Using react-chunked-upload in a project? Open a Show and tell issue to share what you built. Real-world use cases help guide compatibility and future releases.
Bug reports and focused feature requests are welcome. See CONTRIBUTING.md before opening a pull request.
License
MIT
