Skip to main content
Being Idea Innovations
How to Select and Preview an Image in JavaScript (FileReader vs Object URLs)
Back to Blog

How to Select and Preview an Image in JavaScript (FileReader vs Object URLs)

25 April 20186 min readJavascript
Share:

Showing a preview of an image the moment a user selects it is one of the small touches that makes an upload form feel finished. There are two ways to do it in JavaScript, and most tutorials teach the slower one.

This guide covers FileReader, the faster URL.createObjectURL() alternative, multiple file previews, drag and drop, validation that is actually meaningful, and resizing images in the browser before upload.

The quick version

<input type="file" id="picker" accept="image/*" />
<img id="preview" alt="" hidden />
const picker  = document.getElementById('picker');
const preview = document.getElementById('preview');

picker.addEventListener('change', () => {
  const file = picker.files[0];
  if (!file) return;

  // Object URLs are synchronous and cheap — no file read required.
  preview.src = URL.createObjectURL(file);
  preview.hidden = false;
});

// Free the blob once the browser has decoded the image.
preview.addEventListener('load', () => {
  URL.revokeObjectURL(preview.src);
});

That is the whole thing. No FileReader, no async callback.

FileReader vs createObjectURL: which to use

The classic approach reads the file into a base64 data URL:

const reader = new FileReader();
reader.addEventListener('load', () => {
  preview.src = reader.result;   // "data:image/png;base64,iVBORw0..."
});
reader.readAsDataURL(file);

It works, but it is the wrong default:

  • It copies the entire file into memory as a string. Base64 inflates the data by roughly 33%, so a 12 MB photo becomes a ~16 MB string held in JavaScript memory.
  • It is asynchronous — you wait for a read that createObjectURL does not need to perform at all. An object URL is just a pointer to the blob the browser already has on disk.
  • It is measurably slower for large images, and the gap widens as file size grows.

Use URL.createObjectURL() for previews. Reach for FileReader only when you genuinely need the file's bytes in JavaScript — inspecting a file header, parsing a CSV with readAsText(), or embedding a data URL into generated HTML or a PDF.

The memory leak nobody mentions

Every object URL you create pins its blob in memory until the document is discarded or you explicitly release it. In a long-lived single-page app where users preview dozens of images, that adds up to hundreds of megabytes.

Always call URL.revokeObjectURL(url) once the image has loaded. Revoking before the load event fires will break the preview, so hook the event rather than revoking immediately.

Previewing multiple images

<input type="file" id="picker" accept="image/*" multiple />
<div id="gallery" class="gallery"></div>
const gallery = document.getElementById('gallery');

picker.addEventListener('change', () => {
  gallery.replaceChildren();  // clear any previous selection

  for (const file of picker.files) {
    if (!file.type.startsWith('image/')) continue;

    const img = new Image();
    img.src = URL.createObjectURL(file);
    img.alt = file.name;
    img.addEventListener('load', () => URL.revokeObjectURL(img.src), { once: true });

    const figure = document.createElement('figure');
    const caption = document.createElement('figcaption');
    caption.textContent = `${file.name} — ${formatBytes(file.size)}`;

    figure.append(img, caption);
    gallery.append(figure);
  }
});

function formatBytes(bytes) {
  const units = ['B', 'KB', 'MB', 'GB'];
  let i = 0;
  while (bytes >= 1024 && i < units.length - 1) { bytes /= 1024; i++; }
  return `${bytes.toFixed(1)} ${units[i]}`;
}

Note picker.files is a FileList, not an array. It is iterable with for…of, but if you want .map() or .filter() convert it first with Array.from(picker.files).

Validating the selection

const MAX_BYTES = 5 * 1024 * 1024;               // 5 MB
const ALLOWED = ['image/jpeg', 'image/png', 'image/webp', 'image/avif'];

function validate(file) {
  if (!ALLOWED.includes(file.type)) {
    return `${file.name}: only JPEG, PNG, WebP and AVIF are accepted.`;
  }
  if (file.size > MAX_BYTES) {
    return `${file.name} is ${formatBytes(file.size)}; the limit is 5 MB.`;
  }
  return null;
}

Two things to be clear about. The accept="image/*" attribute only filters the file picker dialogue — users can switch it to "All files" and pick anything, so it is a convenience, not a control. And file.type comes from the operating system's guess based on the file extension. Renaming payload.php to photo.jpg will report image/jpeg.

Client-side validation is for user experience only. Every check here must be repeated on the server, where you should verify the real content type by reading the file's magic bytes rather than trusting the extension or the browser-supplied MIME type.

Adding drag and drop

const drop = document.getElementById('dropzone');

['dragenter', 'dragover'].forEach((evt) =>
  drop.addEventListener(evt, (e) => {
    e.preventDefault();               // required, or the browser opens the file
    drop.classList.add('is-over');
  })
);

['dragleave', 'drop'].forEach((evt) =>
  drop.addEventListener(evt, (e) => {
    e.preventDefault();
    drop.classList.remove('is-over');
  })
);

drop.addEventListener('drop', (e) => {
  const files = [...e.dataTransfer.files].filter((f) => f.type.startsWith('image/'));
  if (files.length) renderPreviews(files);
});

Calling preventDefault() on dragover is not optional — without it the browser's default behaviour takes over and navigates away to open the dropped file.

Keep the real <input type="file"> in the DOM rather than replacing it with a drop zone. Drag and drop is unusable by keyboard, and a visually hidden input paired with a <label> keeps the control reachable for everyone.

Resizing before upload

Phone cameras produce 4–12 MB images. Uploading those to display a 400px avatar wastes your users' data and your storage. Downscale in the browser first:

async function resizeImage(file, maxDimension = 1600, quality = 0.85) {
  const bitmap = await createImageBitmap(file);

  const scale = Math.min(1, maxDimension / Math.max(bitmap.width, bitmap.height));
  if (scale === 1) return file;   // already small enough

  const canvas = new OffscreenCanvas(
    Math.round(bitmap.width * scale),
    Math.round(bitmap.height * scale)
  );
  canvas.getContext('2d').drawImage(bitmap, 0, 0, canvas.width, canvas.height);

  const blob = await canvas.convertToBlob({ type: 'image/webp', quality });
  bitmap.close();   // release the decoded bitmap

  return new File([blob], file.name.replace(/\.\w+$/, '.webp'), { type: 'image/webp' });
}

createImageBitmap() decodes off the main thread, so it will not freeze the page the way loading into an <img> can. Calling bitmap.close() releases the decoded pixels immediately instead of waiting for garbage collection — worth doing when handling several large photos.

One caveat: re-encoding through a canvas strips EXIF metadata. That removes GPS coordinates, which is usually a privacy win, but it also drops the orientation flag. Modern browsers apply EXIF orientation when decoding, so createImageBitmap generally gives you correctly rotated output — but test with real phone photos rather than assuming.

Accessibility and polish

  • Give every preview a meaningful alt. The filename is a reasonable fallback; an empty string is right only if the preview is purely decorative and the filename is shown as text alongside.
  • Announce validation errors in an aria-live="polite" region so screen reader users hear them.
  • Reserve space for the preview with aspect-ratio so the page does not jump when the image appears — that shift counts against your Cumulative Layout Shift score.
  • Let users remove an individual file before submitting. Because FileList is read-only, keep your own array of selected files and rebuild a DataTransfer to write back to the input.

Summary

Use URL.createObjectURL() for image previews — it is synchronous, avoids a full copy of the file in memory, and is faster on large images. Remember revokeObjectURL() on load. Keep FileReader for the cases where you actually need the file's contents. Validate on the client for feedback and on the server for safety, and downscale large photos before upload so your users are not sending 12 MB to display a thumbnail.

Share:

Written by

Amit Verma

Founder / Senior Software Engineer

Amit leads engineering at Being Idea. With 15+ years building scalable software products across global markets, he drives architecture decisions and engineering culture across every engagement.

More articles by Amit

Want to talk tech?

We ship software that scales. Let's work together.

No long-term contracts
Senior engineers only
US · AU · NZ timezone coverage
14-day trial on retainers