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_namecolumn to thefilestable: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?