Upload Progress and Cancellation
Tracking bytes received
For large uploads, users need a progress indicator. The server tracks bytes as they arrive:
busboy.on("file", (fieldName, stream, info) => {
let bytesReceived = 0;
const totalSize = parseInt(request.headers.get("content-length") ?? "0", 10);
stream.on("data", (chunk: Buffer) => {
bytesReceived += chunk.length;
// Log progress (in production, send via SSE or WebSocket)
if (totalSize > 0) {
const percent = Math.round((bytesReceived / totalSize) * 100);
console.log(`Upload progress: ${percent}% (${bytesReceived}/${totalSize})`);
}
});
}); Progress via SSE
For a real progress UI, stream progress events to the client via SSE. The client opens an SSE connection, starts the upload, and receives progress events:
// Upload progress tracking (server-side)
const uploadProgress = new Map<string, { received: number; total: number }>();
// The upload route sets progress
route.post("/files", {
resolve: async (c) => {
const uploadId = crypto.randomUUID();
const total = parseInt(c.request.headers.get("content-length") ?? "0", 10);
uploadProgress.set(uploadId, { received: 0, total });
// ... in the busboy file handler:
stream.on("data", (chunk: Buffer) => {
const progress = uploadProgress.get(uploadId);
if (progress) progress.received += chunk.length;
});
// Clean up on completion
// uploadProgress.delete(uploadId);
return Response.json({ id: fileId, uploadId }, { status: 201 });
},
});
// SSE endpoint for progress
route.get("/uploads/:uploadId/progress", {
resolve: (c) => {
const uploadId = c.params.uploadId;
const stream = new ReadableStream({
start(controller) {
const interval = setInterval(() => {
const progress = uploadProgress.get(uploadId);
if (!progress) {
controller.enqueue(`data: ${JSON.stringify({ status: "complete" })}\n\n`);
clearInterval(interval);
controller.close();
return;
}
const percent =
progress.total > 0 ? Math.round((progress.received / progress.total) * 100) : 0;
controller.enqueue(
`data: ${JSON.stringify({ percent, received: progress.received, total: progress.total })}\n\n`,
);
}, 500);
},
});
return new Response(stream, {
headers: {
"content-type": "text/event-stream",
"cache-control": "no-cache",
},
});
},
}); Client-side progress with fetch
Modern browsers track upload progress with XMLHttpRequest (not fetch, which does not support upload progress):
// Client-side
function uploadFile(file: File, onProgress: (percent: number) => void): Promise<any> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const formData = new FormData();
formData.append("file", file);
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const percent = Math.round((event.loaded / event.total) * 100);
onProgress(percent);
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText));
} else {
reject(new Error(xhr.responseText));
}
};
xhr.onerror = () => reject(new Error("Upload failed"));
xhr.open("POST", "/files");
xhr.send(formData);
});
} [!NOTE] The
fetchAPI does not support upload progress tracking as of early 2025. UseXMLHttpRequestfor upload progress or the server-side SSE approach.
Cancellation
The client can abort an upload mid-stream:
// Client-side with XMLHttpRequest
const xhr = new XMLHttpRequest();
// ... setup ...
xhr.send(formData);
// Cancel after 5 seconds
setTimeout(() => xhr.abort(), 5000); On the server, the request body stream will end abruptly. The busboy close event fires, and any partial file should be cleaned up:
busboy.on("close", () => {
if (!savedFile) {
// Upload was cancelled or incomplete — clean up any partial file
if (writeStream) writeStream.destroy();
if (tempFilePath) {
try {
unlinkSync(tempFilePath);
} catch {}
}
}
}); Exercises
Exercise 1: Add byte counting to the upload route. Upload a file and watch the progress logs.
Exercise 2: Implement the SSE progress endpoint. Upload a large file while polling the progress endpoint from another terminal.
Exercise 3: Start an upload and abort it midway (with xhr.abort() or by killing the curl process). Verify the partial file is cleaned up from disk.
Why can't the fetch API track upload progress?