How to Create Image Thumbnails Before Uploading
Create image thumbnails before upload with FileReader previews, Canvas toBlob resizing, FormData upload, and multer field matching.
A preview displays the user’s selected file at a smaller size but uploads the exact same bytes; a thumbnail is a genuinely re-encoded, smaller image you can upload alongside — or instead of — the original.
If you have ever shipped what you thought was a thumbnail feature, then watched a 12 MB phone photo crawl up to the server anyway, you already know the difference matters. Most tutorials blur this distinction and call a scaled-down <img> a “thumbnail,” which is wrong: shrinking an image on screen with CSS changes nothing on the wire. This article separates the two, then walks from the simplest FileReader preview to a real Canvas-resized thumbnail you can upload, plus the cleanup and validation gotchas that break upload UIs in production.
Key Takeaways
- A
FileReader.readAsDataURLpreview is display-only: it uploads the exact original bytes and reduces nothing on the wire. - To produce a smaller file you must redraw the image onto a
<canvas>and re-encode it withcanvas.toBlob(callback, 'image/jpeg', 0.7). canvas.toBlob()is asynchronous and delivers the Blob to a callback rather than returning it, so wrap it in a Promise toawaitthe result.- Upload both files in one
FormData(the originalFileand the thumbnailBlobwith an explicit filename), then receive them server-side with multer’supload.fields(). - Every
URL.createObjectURL()must be paired withURL.revokeObjectURL(), or the underlying file stays in memory until the document unloads.
Preview vs. real thumbnail: which do you need?
Decide this before writing any code, because the two paths share almost no logic. A preview confirms visually that the right file was selected. A thumbnail is a new, smaller image asset (fewer bytes, smaller dimensions) that reduces upload time and server-side processing, and can be stored as a grid image without re-processing the original.
| Goal | Technique | Produces a new smaller file? | Use when |
|---|---|---|---|
| Show the picked image instantly | FileReader or URL.createObjectURL(file) | No | You only need visual confirmation |
| Reduce uploaded bytes / store a small variant | Canvas + toBlob() | Yes | You upload to a server or CDN |
| Drag-drop, progress, multiple sizes, validation | Library (FilePond) | Yes | You want batteries included |
A FileReader readAsDataURL preview does not shrink anything on the wire. To actually reduce uploaded bytes you must redraw the image onto a <canvas> and re-encode it with canvas.toBlob().
How do you preview an image with FileReader?
Discover how at OpenReplay.com.
For a display-only preview, listen for the file input’s change event, read the file with FileReader.readAsDataURL, and assign the resulting data URL to an <img> in reader.onload. This is the fastest thing to ship, and it does not resize the file.
<input type="file" id="fileInput" accept="image/*" multiple>
<div id="previews"></div>
const input = document.getElementById('fileInput');
const previews = document.getElementById('previews');
input.addEventListener('change', (e) => {
previews.innerHTML = '';
Array.from(e.target.files).forEach((file) => {
if (!file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = (ev) => {
const img = new Image();
img.src = ev.target.result; // base64 data URL
img.alt = `Preview of ${file.name}`;
previews.appendChild(img);
};
reader.readAsDataURL(file);
});
});
Note the Array.from(files).forEach(...) pattern. The common jQuery multiple-file tutorial reuses one reader variable inside a for loop, so every onload closes over the last file: a closure bug that shows the same image repeatedly. Each file needs its own FileReader inside the iteration, which forEach gives you for free.
A lighter alternative for large files is URL.createObjectURL(file), which returns a short blob URL instead of a base64 string and avoids the memory bloat of encoding the whole file into a data URL, at the cost of a mandatory revokeObjectURL() later.
How do you generate a resized thumbnail with Canvas?
To produce an actual smaller file, load the image, scale its dimensions while preserving aspect ratio, draw it to a canvas, and re-encode. To preserve aspect ratio, scale both dimensions by the same factor (maxSize / longestSide) instead of setting width and height independently.
canvas.toBlob(callback, 'image/jpeg', 0.7) is asynchronous: it delivers the Blob to its callback rather than returning it, so wrap it in a Promise if you want to await the thumbnail. A regular canvas has no promise-returning form; only OffscreenCanvas.convertToBlob() returns a Promise natively.
function canvasToBlob(canvas, type, quality) {
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error('toBlob failed'))),
type,
quality
);
});
}
async function makeThumbnail(file, maxSize = 200) {
const url = URL.createObjectURL(file);
try {
const img = await new Promise((res, rej) => {
const i = new Image();
i.onload = () => res(i);
i.onerror = rej;
i.src = url;
});
const scale = Math.min(1, maxSize / Math.max(img.width, img.height));
const w = Math.round(img.width * scale);
const h = Math.round(img.height * scale);
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
return await canvasToBlob(canvas, 'image/jpeg', 0.7);
} finally {
URL.revokeObjectURL(url);
}
}
The third argument to toBlob sets encoding quality on a scale from 0 to 1. Only lossy formats read it, so it changes the output for image/jpeg and image/webp and does nothing for image/png. A value of 0.6–0.8 is the practical sweet spot. Re-encoding a multi-megapixel photo down to a 200px JPEG typically cuts its size by one to two orders of magnitude, which is the entire point of doing this on the client.
Show the thumbnail and upload both files
Display the generated Blob with URL.createObjectURL(blob), then send it to the server alongside the original. For upload, append both files to one FormData: the original File and the thumbnail Blob, passing a filename as the third argument. POST that with fetch, and on the server multer’s upload.fields() receives the two fields separately.
async function upload(file) {
const thumb = await makeThumbnail(file);
const preview = new Image();
preview.src = URL.createObjectURL(thumb); // remember to revoke later
document.body.appendChild(preview);
const form = new FormData();
form.append('originalFiles', file, file.name);
form.append('thumbnails', thumb, `thumb-${file.name}.jpg`);
await fetch('/api/upload', { method: 'POST', body: form });
}
On the backend, multer parses the multipart body. The field names in upload.fields() must match the FormData.append keys exactly, or that field is silently dropped:
const upload = multer({ dest: 'uploads/' });
app.post('/api/upload', upload.fields([
{ name: 'originalFiles', maxCount: 10 },
{ name: 'thumbnails', maxCount: 10 },
]), (req, res) => res.json({ ok: true }));
Multer’s 2.x line carries the security fixes that the 1.x line lacks, and its package.json sets the floor at Node.js 10.16.0, not Node 18. The Node 18 minimum arrives with the 3.x line, which is still in alpha. The newest stable release in the multer changelog is 2.2.0, so check there before pinning a version.
The library option: FilePond and friends
When you also want drag-and-drop, upload progress, validation, and multiple resized variants, reach for FilePond with its image plugins instead of hand-rolling everything. The filepond-plugin-image-preview plugin renders the preview, filepond-plugin-image-resize writes resize metadata, and filepond-plugin-image-transform performs the actual resize and hands you output Blobs.
FilePond.registerPlugin(
FilePondPluginImagePreview,
FilePondPluginImageResize,
FilePondPluginImageTransform
);
FilePond.create(document.querySelector('input[type="file"]'), {
imageResizeTargetWidth: 256,
imageResizeMode: 'contain',
});
Pin FilePond to the version-4 line (@^4), as the FilePond installation docs advise. The 4.x line is the stable one, at 4.32.12 in the changelog, while v5 is still in beta. Loading unpkg.com/filepond unpinned tracks the latest tag, so today it serves the current stable release, but it will move you onto the next major the day that line is promoted.
For higher-quality downscaling specifically, Pica (10.0.2) applies a proper resampling filter and can run in a Web Worker; select its algorithm via the filter option, e.g. { filter: 'lanczos3' }. The browser-image-compression package (2.0.2) is another option, but its last release was in March 2023 and Snyk rates its maintenance as inactive, so weigh that before adopting it.
Gotchas and best practices
These are the failure modes that session replays of upload UIs frequently surface (silent memory growth, rotated thumbnails, and tabs that hang on huge files):
- Revoke object URLs. Always call
URL.revokeObjectURL()when you remove a preview: everycreateObjectURL()holds the underlying file in memory until the URL is explicitly revoked or the document unloads. Unbounded object URLs are a classic source of gradual tab memory growth. - FileList is read-only. Because a
FileListis read-only, you cannot splice a file out of an<input>, so track an editable array of your own and rebuild the upload from it. - Validate before processing. Combine
accept="image/*"with a runtimefile.type.startsWith('image/')check and a size cap.acceptis a UX hint, not enforcement. - Cap dimensions to protect the tab. Very large images can exhaust memory and crash the tab during decode; reject files above a byte threshold and clamp
maxSizebefore drawing. - EXIF rotation. Resizing through a canvas can drop the EXIF orientation flag, so a portrait phone photo may come out sideways. Always test with real portrait photos from a phone.
- Downscale quality. For large downscales, a single
drawImage()can look rough; resize in steps or use a library like Pica, which applies a proper resampling filter for sharper thumbnails. - Canvas tainting only affects cross-origin images loaded from other domains; user-selected files never taint the canvas, so no
crossOriginhandling is needed here. - Accessibility. Give every preview
<img>meaningfulalttext and label remove buttons with ARIA so the UI is usable without sight of the thumbnail.
Wrapping up
Pick the path that matches your goal: a FileReader or object-URL preview when you only need visual confirmation, and a Canvas toBlob thumbnail when you actually need fewer bytes on the wire. Start with the Promise-wrapped makeThumbnail helper above, upload the original and the thumbnail together in one FormData, and wire up revokeObjectURL cleanup from the first commit rather than bolting it on after a memory leak shows up.
FAQs
Does creating a preview reduce the size of the file the user uploads?
No. A FileReader readAsDataURL preview or a URL.createObjectURL preview is display-only and uploads the exact original bytes with zero reduction on the wire. To actually shrink the uploaded file you must redraw the image onto a canvas at smaller dimensions and re-encode it with canvas.toBlob, then upload that Blob instead of, or alongside, the original.
Why is my canvas-generated thumbnail rotated the wrong way?
Resizing an image through a canvas can drop the EXIF orientation flag that phones store on portrait photos, so a correctly-oriented original comes out sideways in the thumbnail. Browsers auto-orient a plain img element, but drawImage does not always carry that orientation onto the canvas. Always test your resize path with real portrait photos taken on a phone rather than only landscape test images.
How do I await canvas.toBlob when it only takes a callback?
Wrap it in a Promise, because canvas.toBlob is asynchronous and hands the Blob to its callback rather than returning it. Create a helper that returns new Promise and calls canvas.toBlob with resolve, rejecting when the callback receives null. A regular canvas has no native promise-returning form; only OffscreenCanvas.convertToBlob returns a Promise directly.
Why does my thumbnail field never arrive on the multer backend?
The field names passed to multer's upload.fields must match the FormData.append keys on the client exactly, or that field is silently dropped with no error. If you append 'thumbnails' on the client, your server must declare name 'thumbnails' in upload.fields. Also check your multer version: the 2.x line carries security fixes that the 1.x line lacks.