Technology Aug 23, 2026 · 6 min read

You Probably Don't Need a Server For That

You Probably Don't Need a Server for That A few months ago I needed to convert about forty HEIC photos from my phone into PNGs. The first three results on Google were all the same shape: drag your files here, we'll upload them, we'll email you a download link. For photos. That were alrea...

DE
DEV Community
by Alex Johnson
You Probably Don't Need a Server For That

You Probably Don't Need a Server for That

A few months ago I needed to convert about forty HEIC photos from my phone into PNGs. The first three results on Google were all the same shape: drag your files here, we'll upload them, we'll email you a download link.

For photos. That were already on my laptop. That never needed to go anywhere.

That pattern made sense in 2012. The browser couldn't read binary files, couldn't decode images off the main thread, and had no cryptography to speak of. If you wanted to do real work with a file, a server had to do it.

That stopped being true a while ago, and a surprising amount of tooling hasn't caught up. Here's what the platform actually gives you now.

Reading files without uploading them

The File object you get from an <input type="file"> is a Blob. You can read it directly:

const input = document.getElementById('file-input');

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

  const buffer = await file.arrayBuffer();
  console.log(`${file.name}: ${buffer.byteLength} bytes`);
});

arrayBuffer(), text(), and stream() are all available on Blob and have been for years. No FileReader callback dance required unless you're supporting genuinely old browsers.

Drag and drop is the same data through a different event:

dropzone.addEventListener('dragover', (e) => {
  e.preventDefault();
  dropzone.classList.add('active');
});

dropzone.addEventListener('drop', (e) => {
  e.preventDefault();
  dropzone.classList.remove('active');
  handleFiles(e.dataTransfer.files);
});

The preventDefault() on dragover is the part everyone forgets. Without it the browser navigates to the file instead of firing your drop handler.

Hashing with Web Crypto

crypto.subtle gives you SHA-256 and SHA-512 natively. It's async, it runs off the main thread, and it's been in every major browser for years:

async function sha256(text) {
  const data = new TextEncoder().encode(text);
  const digest = await crypto.subtle.digest('SHA-256', data);
  return [...new Uint8Array(digest)]
    .map(b => b.toString(16).padStart(2, '0'))
    .join('');
}

Two things worth knowing. crypto.subtle requires a secure context, so it's undefined on plain HTTP. It works on localhost while you're developing, but deploy to plain HTTP and it silently vanishes, leaving you staring at a confusing "cannot read property digest of undefined."

MD5 isn't in the spec, and that's deliberate. The WebCrypto working group only included algorithms it considered appropriate for the browser. If you need MD5 for checksum verification against a legacy system, you implement it yourself or pull a library, and you tell users plainly it's for checksums, not security. Same goes for SHA-1. I built a hash generator that shows all four side by side and labels the two legacy ones explicitly, because "which of these can I actually trust" is the real question people have.

Images: canvas does more than you'd think

Format conversion is a two-step trick: decode into a canvas, then encode out of it.

async function convertToPng(file) {
  const bitmap = await createImageBitmap(file);
  const canvas = document.createElement('canvas');
  canvas.width = bitmap.width;
  canvas.height = bitmap.height;
  canvas.getContext('2d').drawImage(bitmap, 0, 0);

  return new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
}

createImageBitmap() is the modern path. It decodes off the main thread, so large images don't jank your UI the way the old new Image() + onload approach does.

toBlob() takes a quality argument for lossy formats:

canvas.toBlob(blob => { /* ... */ }, 'image/jpeg', 0.8);

That's your entire image compressor. Decode, redraw, re-encode at lower quality, compare blob.size to file.size. The whole thing is maybe thirty lines.

Two gotchas will bite you here.

Converting a PNG with an alpha channel to JPEG turns transparent pixels black, because canvas initializes to transparent black and JPEG has no alpha channel to preserve. Fill the canvas first if you're targeting JPEG:

ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(bitmap, 0, 0);

And HEIC: Safari decodes it, nothing else does. createImageBitmap() throws on a HEIC file in Chrome and Firefox. You need a WASM decoder, and heic2any is the usual choice. It's a real dependency, not a small one, so lazy-load it only when someone actually drops a HEIC file rather than shipping it on every page load.

Downloading the result

Object URLs, and remember to revoke them:

function download(blob, filename) {
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = filename;
  a.click();
  URL.revokeObjectURL(url);
}

Skipping revokeObjectURL() leaks the entire blob for the lifetime of the document. Fine for one file, less fine when someone batch-converts forty photos.

Where this approach runs out

I don't want to oversell it. Client-side isn't free.

Memory is the real ceiling. A 50MP image decoded to a canvas runs about 200MB of RGBA in memory, and mobile Safari kills your tab well before desktop Chrome even complains. Batch operations need to process one file at a time and release each result, not hold everything in memory at once.

The main thread is precious too. Anything genuinely heavy belongs in a Web Worker. Canvas operations can't move there directly, but OffscreenCanvas covers a good chunk of that gap now.

Some things genuinely need a server: video transcoding, OCR, anything requiring a model, anything requiring a secret. FFmpeg-in-WASM exists and it's impressive, but shipping a 25MB binary to convert one clip is a worse experience than just uploading it.

And there's no shared state. Nothing persists, nothing syncs. That's the tradeoff you're accepting.

Why bother

The obvious argument is privacy. Files that never leave the device can't be breached, retained, or quietly used as training data. That matters more for some content than others, and "we delete after an hour" requires trusting a claim you can't verify.

But the argument I find more persuasive is that it's just better. No upload wait. No download wait. No queue. No file size cap because someone's paying for bandwidth. No 500 error because a worker died. It works offline. Latency is however fast the user's laptop is, which for a 2MB image is imperceptible.

I ended up building a set of these tools partly because I kept needing them and partly to see where the ceiling actually was. It's further out than I expected. The JSON formatter handles files large enough that I'd assumed I'd need to stream them. The image compressor does batches that I'd have reflexively put on a server five years ago.

If you're building something in this space, my honest advice: try the client-side version first. You'll be surprised how often the server was optional.

DE
Source

This article was originally published by DEV Community and written by Alex Johnson.

Read original article on DEV Community
Back to Discover

Reading List