Use ky to send files or generated byte streams and consume downloads with progress callbacks. Ky builds on Fetch: pass standard FormData, Blob, or ReadableStream values through body, rather than serializing them with json.
The samples run in a browser project with ky installed:
bashnpm install ky
Replace the example API URLs with endpoints that accept your uploads or serve your files. Cross-origin endpoints need the appropriate CORS configuration. Upload progress and streaming uploads also need request stream support; Chromium-based browsers require HTTP/2 for HTTPS connections.
1. Upload a multipart file
Pass a FormData instance to ky.post(). This sample sends a small text file and a description, logs upload progress where supported, and logs the response status after the request succeeds.
tsimport ky from 'ky';
const file = new File(['Transfer example\n'], 'notes.txt', {
type: 'text/plain',
});
const formData = new FormData();
formData.append('file', file);
formData.append('description', 'Example notes');
async function uploadFile() {
const response = await ky.post('https://api.example.com/uploads', {
body: formData,
retry: {limit: 0},
onUploadProgress: (progress, chunk) => {
console.log({
percent: progress.percent * 100,
transferredBytes: progress.transferredBytes,
totalBytes: progress.totalBytes,
chunkBytes: chunk.byteLength,
});
},
});
console.log('Upload response status:', response.status);
}
uploadFile().catch(console.error);
Leave Content-Type unset so Fetch generates multipart/form-data with the boundary matching the encoded body. An explicit Content-Type in headers takes precedence; Ky does not repair an explicitly supplied multipart header.
If a beforeRequest hook replaces the form with a new FormData, delete request.headers's content-type entry before returning new Request(request, {body: newFormData}). This lets the request constructor generate a boundary for the replacement body.
For text-only fields that your endpoint expects as application/x-www-form-urlencoded, use URLSearchParams instead. This sends two encoded fields and returns the response without attempting to parse an upload receipt:
tsimport ky from 'ky';
const fields = new URLSearchParams();
fields.set('food', 'fries');
fields.set('drink', 'icetea');
async function submitForm() {
const response = await ky.post('https://api.example.com/orders', {
body: fields,
});
console.log('Form response status:', response.status);
}
submitForm().catch(console.error);
2. Send a streaming body without retry buffering
Pass a ReadableStream<Uint8Array> through body. Ky sets duplex: 'half' for you in environments with request stream support. This sample sends two text chunks and logs the response status after the request succeeds:
tsimport ky from 'ky';
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode('first line\n'));
controller.enqueue(encoder.encode('second line\n'));
controller.close();
},
});
async function uploadStream() {
const response = await ky.post('https://api.example.com/uploads/raw', {
body: stream,
headers: {'content-type': 'text/plain'},
retry: {limit: 0},
});
console.log('Stream upload response status:', response.status);
}
uploadStream().catch(console.error);
Set retry: {limit: 0} when you do not need replay. With a positive retry limit, Ky clones the request before sending it. Cloning a streaming body uses tee() and buffers the body in memory for a possible retry. Disabling retries skips that clone, which matters for large uploads.
3. Consume a download and report progress
Consume the response body to drive download progress. Calling await ky.get(url) alone does not demonstrate a completed download; the callback wraps the response stream.
This sample reads each chunk without collecting the whole file in a Blob. It logs progress and a completion message for an accepted download, and cancels the reader if the body exceeds a 20 MiB application limit. The limit counts the bytes you actually read, not a server-provided size estimate.
tsimport ky from 'ky';
async function downloadFile() {
const maxBytes = 20 * 1024 * 1024;
const response = await ky.get('https://api.example.com/files/report.csv', {
onDownloadProgress: (progress, chunk) => {
console.log({
percent: progress.percent * 100,
transferredBytes: progress.transferredBytes,
totalBytes: progress.totalBytes,
chunkBytes: chunk.byteLength,
});
},
});
if (!response.body) {
throw new Error('The response has no body stream');
}
const reader = response.body.getReader();
let receivedBytes = 0;
try {
while (true) {
const {done, value} = await reader.read();
if (done) {
break;
}
receivedBytes += value.byteLength;
if (receivedBytes > maxBytes) {
await reader.cancel('Download exceeds the application limit');
throw new Error(`Download exceeds ${maxBytes} bytes`);
}
console.log('Accepted chunk bytes:', value.byteLength);
}
console.log('Download consumed:', receivedBytes, 'bytes');
} finally {
reader.releaseLock();
}
}
downloadFile().catch(console.error);
If you need the complete file in memory instead, call await ky.get(url, {onDownloadProgress: callback}).blob() with your callback. The shortcut returns a Blob after consuming the body; it buffers the complete download rather than processing chunks individually.
Options and progress values
The callbacks receive a Progress object and a Uint8Array chunk. percent ranges from 0 to 1; multiply it by 100 for display. totalBytes is an estimate, not a size limit. Download totals start from Content-Length; multipart upload sizes are approximate, and a streaming upload may have no known total.
| Option | Type | Default | What it does |
|---|---|---|---|
body | BodyInit | null | Not set | Sends a standard Fetch body. |
onUploadProgress | (progress: Progress, chunk: Uint8Array) => void | Not set | Reports upload-stream progress where supported. |
onDownloadProgress | (progress: Progress, chunk: Uint8Array) => void | Not set | Reports progress as you consume the response stream. |
retry.limit | number | 2 | Controls retries; 0 skips request cloning for replay. |
maxResponseSize (next release only) | number | Infinity | Limits consumed response-body bytes. |
Progress reaches 1 when the wrapped stream finishes. Do not treat upload progress reaching 1 as the server's acknowledgement: await the response separately, as the upload samples do. For an empty body stream, the completion callback receives an empty chunk; a response with no body stream has no download callback.
Response-size limits and pitfalls
- Response-size limits: The reader loop above enforces an application limit while processing download chunks with the released API; see Handle request errors for next-release size enforcement and error handling.
- Upload support:
onUploadProgressis silently ignored without request stream support, withkeepalive: true, or withmode: 'no-cors'. Ignoring the callback does not make a streaming body compatible with those environments; use a compatible non-stream body when needed. - Download support:
onDownloadProgressrequires response streams. Ky throws ifReadableStreamsupport is missing. - Error responses: Ky throws
HTTPErrorfor non-2xx responses by default. Its response body is consumed to populateerror.data; use that property rather than readingerror.responseagain. See Handle request errors.
Related
- Retry failed requests explains method eligibility and retry policy.
- Cancel requests shows how to abort transfers with a signal.
- Request lifecycle explains where hooks can replace requests and responses.
Was this page helpful?