hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses File Uploads and Storage with @hectoday/http

The Basics

  • Why File Uploads Are Hard
  • Project Setup

Receiving Files

  • Multipart Form Data
  • Validating Uploads
  • Saving to Disk

Serving Files

  • Serving Static Files
  • Range Requests and Resumable Downloads
  • Access Control on Files

Production Patterns

  • Streaming Uploads
  • Image Processing
  • Upload Progress and Cancellation

Cloud Storage

  • Presigned URLs
  • Moving from Local to Cloud

Putting It All Together

  • File Upload Checklist
  • Capstone: File Sharing API

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 fetch API does not support upload progress tracking as of early 2025. Use XMLHttpRequest for 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?

← Image Processing Presigned URLs →

© 2026 hectoday. All rights reserved.