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

Image Processing

Why process images on upload

A user uploads a 5000×4000 pixel photo from their phone (8 MB). Your app displays it as a 200×150 thumbnail in a list and a 800×600 preview in the detail view. Without processing, the client downloads 8 MB to display a 200×150 image. With processing, the server creates multiple sizes at upload time.

Sharp

sharp is the fastest image processing library for Node.js. It uses libvips under the hood, which processes images without loading them entirely into memory.

// src/image.ts
import sharp from "sharp";
import { join } from "node:path";
import { UPLOAD_DIR, THUMB_DIR } from "./storage.js";

const THUMBNAIL_SIZE = { width: 200, height: 200 };
const PREVIEW_SIZE = { width: 800, height: 600 };

export async function generateThumbnail(storedName: string): Promise<string> {
  const inputPath = join(UPLOAD_DIR, storedName);
  const thumbName = `thumb_${storedName}`;
  const outputPath = join(THUMB_DIR, thumbName);

  await sharp(inputPath)
    .resize(THUMBNAIL_SIZE.width, THUMBNAIL_SIZE.height, {
      fit: "cover", // Crop to fill the dimensions
      position: "center",
    })
    .jpeg({ quality: 80 })
    .toFile(outputPath);

  return thumbName;
}

export async function generatePreview(storedName: string): Promise<string> {
  const inputPath = join(UPLOAD_DIR, storedName);
  const previewName = `preview_${storedName}`;
  const outputPath = join(UPLOAD_DIR, previewName);

  await sharp(inputPath)
    .resize(PREVIEW_SIZE.width, PREVIEW_SIZE.height, {
      fit: "inside", // Scale down to fit within dimensions (no crop)
      withoutEnlargement: true, // Do not upscale small images
    })
    .jpeg({ quality: 85 })
    .toFile(outputPath);

  return previewName;
}

export function isImage(mimeType: string): boolean {
  return mimeType.startsWith("image/") && mimeType !== "image/svg+xml";
}

fit: "cover" crops the image to fill the exact dimensions (good for thumbnails). fit: "inside" scales the image to fit within the dimensions without cropping (good for previews). withoutEnlargement prevents small images from being upscaled (which looks blurry).

Processing on upload

Generate thumbnails when an image is uploaded:

// After saving the file:
if (isImage(validType)) {
  try {
    const thumbName = await generateThumbnail(storedName);
    db.prepare("UPDATE files SET thumbnail_name = ? WHERE id = ?").run(thumbName, id);
  } catch (err) {
    console.error("Thumbnail generation failed:", err);
    // Do not fail the upload — the thumbnail is a nice-to-have
  }
}

[!NOTE] Add a thumbnail_name column to the files table: ALTER TABLE files ADD COLUMN thumbnail_name TEXT.

Thumbnail generation is wrapped in try/catch. If it fails (corrupted image, unsupported format), the upload still succeeds. The user gets their file; they just do not get a thumbnail.

Serving thumbnails

route.get("/files/:id/thumbnail", {
  resolve: (c) => {
    const file = db.prepare("SELECT thumbnail_name FROM files WHERE id = ?").get(c.params.id) as
      | { thumbnail_name: string | null }
      | undefined;

    if (!file || !file.thumbnail_name) {
      return Response.json({ error: "No thumbnail" }, { status: 404 });
    }

    const thumbPath = join(THUMB_DIR, file.thumbnail_name);
    let stat;
    try {
      stat = statSync(thumbPath);
    } catch {
      return Response.json({ error: "Thumbnail not found" }, { status: 404 });
    }

    const nodeStream = createReadStream(thumbPath);
    const webStream = Readable.toWeb(nodeStream) as ReadableStream;

    return new Response(webStream, {
      headers: {
        "content-type": "image/jpeg",
        "content-length": String(stat.size),
        "cache-control": "public, max-age=604800", // Cache for 1 week
      },
    });
  },
});

Thumbnails are aggressively cached (max-age=604800 = 1 week). They do not change — if the user updates the image, a new thumbnail is generated with a new name.

On-the-fly vs pre-generated

Pre-generated (our approach): Create thumbnails at upload time. Serve them from disk. Fast on read, uses disk space, thumbnails exist even if never viewed.

On-the-fly: Generate thumbnails when requested (GET /files/:id/thumbnail?w=200&h=200). Cache the result. No wasted storage, but the first request is slow. Used by image CDNs like Imgix and Cloudinary.

Pre-generated is simpler and faster for apps with a fixed set of sizes. On-the-fly is better when clients need arbitrary sizes.

Exercises

Exercise 1: Upload an image. Verify a thumbnail is generated in the uploads/thumbnails/ directory.

Exercise 2: Serve the thumbnail. Compare the file size to the original. (A 5 MB photo should produce a ~20 KB thumbnail.)

Exercise 3: Upload a non-image file (PDF, text). Verify no thumbnail is generated and the upload still succeeds.

Exercise 4: Try uploading a corrupted image file. Verify the upload succeeds but the thumbnail generation logs an error.

Why do we use fit: 'cover' for thumbnails and fit: 'inside' for previews?

← Streaming Uploads Upload Progress and Cancellation →

© 2026 hectoday. All rights reserved.