Every image converter you have used probably worked the same way: you upload a file, a server somewhere runs ImageMagick or libvips over it, and you download the result. That architecture made sense when browsers could not decode a JPEG without a plugin.
They can now, and have been able to for a long time. This is a walkthrough of how the conversion on this site is done entirely on your machine, written by the person who built it. It includes the parts that turned out to be harder than expected and the limits the approach genuinely has.
The core insight
Your browser is already a highly optimised image processing library. It has to be: rendering a web page means decoding JPEG, PNG, WebP, GIF and increasingly AVIF at speed, thousands of times a day. Those decoders are written in C, hardware accelerated where possible, and hardened by two decades of security research because they parse untrusted input from the entire internet.
Browsers can also encode. The <canvas> element has been able to produce JPEG and PNG
since roughly 2011, and WebP since Chrome 32.
So a format converter does not need to ship an image library at all. It needs to ask the browser to decode a file and then ask it to encode the result. The whole job is plumbing between two capabilities that are already installed.
The decode step
The modern entry point is createImageBitmap, which takes a File or Blob and returns
a decoded bitmap:
const bitmap = await createImageBitmap(file);
Two things make this the right choice over the older approach of setting img.src to an
object URL and waiting for onload.
It is designed to decode off the main thread. The browser is free to do the work on a
worker thread, so the interface stays responsive while a 40-megapixel scan is being
parsed. With the <img> approach, a large decode can jank the page.
It applies EXIF orientation. Pass { imageOrientation: 'from-image' } and the bitmap
comes back rotated the way the camera intended, so the output is correctly oriented
everywhere and doesn’t rely on the viewer to honour a tag.
When createImageBitmap throws, which is what happens for SVG and for any format this
browser has no decoder for, the code falls back to the <img> path. If that fails too,
the file is reported as jammed and is never quietly skipped.
Which formats can be decoded depends entirely on the browser and not on the site. JPEG, PNG, GIF, BMP and WebP are universal. AVIF works in Chrome, Firefox and Opera, and in recent Safari, but not reliably in older versions. That is why an AVIF conversion can fail on one machine and succeed on another, and why the honest response is an error message and not a silent fallback to uploading the file somewhere.
SVG is a special case, covered below.
The encode step
With a decoded bitmap in hand, the pixels are drawn onto a canvas and the canvas is asked for a blob in the target format:
const canvas = document.createElement('canvas');
canvas.width = width; canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(bitmap, 0, 0);
canvas.toBlob(blob => { /* … */ }, 'image/webp', 0.85);
That is the whole encoder. toBlob hands the work to the browser’s own native encoder
and returns a Blob you can turn into a download with URL.createObjectURL.
The third argument maps to the encoder’s internal quality scale for lossy formats and is ignored for PNG. This is the site’s temperature dial: Cold is 0.6, Warm 0.85, Hot 0.95. There is a separate guide on what those numbers control.
One detail matters: a canvas starts out fully transparent, and formats without an alpha channel have to resolve that to something. Left alone, the result varies by browser and can come out black. So for JPG and GIF targets the canvas gets an explicit white fill before the image is drawn onto it:
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(bitmap, 0, 0);
That single fill is the difference between a logo landing on white as expected and landing on black. It is also, unavoidably, where transparency information is lost. See what survives a conversion.
Where GIF breaks the pattern
Browsers read GIF and refuse to write it. Asking canvas.toBlob for image/gif does not
raise an error. The specification says an unsupported type falls back to PNG, so you get a
PNG file with a .gif name and no warning at all.
So GIF is the one route that needs a real encoder in the page. This site uses gifenc, a small open-source library, and the work it does is interesting: GIF allows only 256 colours per frame, so the encoder must first decide which 256.
The naive approach is a fixed palette, the old “web-safe” 216 colours. It is fast and it looks terrible, because your specific image’s colours are unlikely to be in that set, so everything gets approximated.
The better approach is median cut quantisation. Take all the colours actually present in the image, treat them as points in a three-dimensional colour space, and repeatedly split the box containing them along its longest axis until you have 256 boxes. The average colour of each box becomes a palette entry. Because the palette is derived from the image, a photograph of a forest gets a palette full of greens and a screenshot of a document gets a palette full of greys, each one spending its 256 slots where that image needs them.
This is why converting a logo or a screenshot to GIF often looks perfect while converting a sunset produces visible banding. The logo has fewer than 256 distinct colours to begin with, so quantisation is lossless for it. The sunset has tens of thousands, and 256 is not enough for a smooth gradient.
Batching without melting the tab
Converting one image is easy. Converting a hundred requires care, because the obvious
implementation, Promise.all over every file, decodes all hundred images into memory
simultaneously and either exhausts memory or freezes the tab.
A decoded bitmap is uncompressed: width × height × 4 bytes. A hundred 12-megapixel photos decoded at once is roughly 4.8 gigabytes of raw pixels, which no browser tab will tolerate.
So files are processed strictly one at a time in a plain for … await loop, and each
bitmap is explicitly released with bitmap.close() as soon as it has been drawn to the
canvas. That keeps peak memory at roughly one image regardless of batch size, at the cost
of not using multiple cores. It is the right trade for this workload, since the bottleneck
is memory and not CPU, and a batch that finishes steadily is worth more than one that might
take the tab down at file ninety.
Two hard limits sit in front of all of this: files over 60 MB and images with any side longer than 12,000 pixels are refused before the canvas is allocated. Browsers impose their own canvas ceilings that differ by engine and platform, and refusing early with a clear message beats letting the canvas fail obscurely.
SVG: the awkward one
SVG is not a bitmap format, so createImageBitmap does not accept it directly in every
browser. Rasterising an SVG means loading it into an <img> element via a blob URL and
drawing that to canvas.
Two problems follow.
Intrinsic size: many SVGs declare a viewBox but no width and height, so the <img>
element reports no natural size and there is nothing to rasterise against. Something has to
choose. This site uses the declared size where one exists and falls back to 1024 pixels on
any dimension that comes back empty, which is large enough for most icon and logo uses.
External references: an SVG that references a font by name, or links to an external image,
renders inside an <img> element with those references unresolved, because the browser
deliberately isolates SVG loaded this way for good security reasons. Text set in a font the
SVG does not embed will substitute. Converting text to outlines before export is the fix,
and it has to happen in the originating editor and cannot happen here.
What this architecture cannot do
Being honest about the limits is part of the point:
- No format the browser cannot decode. HEIC is the most requested. Safari can decode it; other browsers cannot, and shipping a HEIC decoder means shipping a multi-megabyte WebAssembly bundle to every visitor for a minority use case. TIFF and RAW formats are in the same position.
- No animation. Decoding a GIF or animated WebP through this path gets you the first frame. Full animation handling requires frame-by-frame demuxing that the standard APIs do not expose.
- No professional colour management. Everything is rendered into standard RGB. For web work this is correct. For CMYK print workflows or wide-gamut photography it is a real limitation.
- No metadata preservation. Re-encoding builds a new file from pixels, so EXIF does not carry over. For most people this is a benefit, but if you need the capture data it is a genuine loss.
- Memory is your machine’s. Very large images are capped at 12,000 pixels on a side and 60 MB per file, and even under those limits a batch that a desktop handles comfortably may struggle on a phone. There is no server with more RAM to fall back on.
Why do it this way at all
The reason is structural.
A server-side converter has to make promises: we delete your files after an hour, we do not look at them, our storage is secure. Those promises may be entirely sincere. They are unverifiable from outside, and they remain only as good as the operator’s current security posture, current business model and current owner.
A client-side converter makes no promises because it has nothing to promise about. There is no upload endpoint, no storage bucket, no processing queue and no retention policy, because there is no server. You can confirm this yourself in thirty seconds with the network tab open, which is a materially different kind of assurance from a paragraph on a policy page.
The trade is real: fewer supported formats, no animation, and a dependence on your own machine’s memory. For converting between the seven formats that cover almost all everyday use, it is a trade worth making.