Skip to content
D
Documentation

Transfer files and streams

how-to
3 min readUpdated

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:

bash
npm 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.

ts
import 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:

ts
import 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:

ts
import 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.

ts
import 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.

OptionTypeDefaultWhat it does
bodyBodyInit | nullNot setSends a standard Fetch body.
onUploadProgress(progress: Progress, chunk: Uint8Array) => voidNot setReports upload-stream progress where supported.
onDownloadProgress(progress: Progress, chunk: Uint8Array) => voidNot setReports progress as you consume the response stream.
retry.limitnumber2Controls retries; 0 skips request cloning for replay.
maxResponseSize (next release only)numberInfinityLimits 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: onUploadProgress is silently ignored without request stream support, with keepalive: true, or with mode: '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: onDownloadProgress requires response streams. Ky throws if ReadableStream support is missing.
  • Error responses: Ky throws HTTPError for non-2xx responses by default. Its response body is consumed to populate error.data; use that property rather than reading error.response again. See Handle request errors.

Was this page helpful?

Transfer files and streams — ky · GPT-6.1 Sol