JPG-to-PNG in JavaScript splits cleanly by environment. Browser: Canvas API in two lines. Node: sharp in two lines. Edge: API in one fetch. All three produce identical output because PNG is lossless — there's no quality knob to tune.

Method 1: Canvas API (browser, zero dependencies)

The browser's built-in Canvas API encodes PNG natively. No library required.

async function jpgToPng(file) {
  const bitmap = await createImageBitmap(file);

  const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
  const ctx = canvas.getContext("2d");
  ctx.drawImage(bitmap, 0, 0);

  return await canvas.convertToBlob({ type: "image/png" });
}

// Wire to a file input:
document.querySelector("input[type=file]").addEventListener("change", async (e) => {
  const blob = await jpgToPng(e.target.files[0]);
  const url = URL.createObjectURL(blob);
  const a = Object.assign(document.createElement("a"), {
    href: url,
    download: "output.png",
  });
  a.click();
  URL.revokeObjectURL(url);
});

To add transparency (e.g., make white pixels transparent for logos saved as JPG):

async function jpgToPngWithAlpha(file, threshold = 240) {
  const bitmap = await createImageBitmap(file);
  const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
  const ctx = canvas.getContext("2d");
  ctx.drawImage(bitmap, 0, 0);

  const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
  const data = imgData.data;
  for (let i = 0; i < data.length; i += 4) {
    if (data[i] > threshold && data[i + 1] > threshold && data[i + 2] > threshold) {
      data[i + 3] = 0;  // alpha = 0 (transparent)
    }
  }
  ctx.putImageData(imgData, 0, 0);

  return await canvas.convertToBlob({ type: "image/png" });
}

The threshold matters — anti-aliased edges have near-white pixels you want to keep visible. 240 is a reasonable default; tune for your specific images.

Method 2: sharp (Node, fastest)

sharp wraps libvips — fastest image library in the Node ecosystem. Two-line conversion.

npm install sharp
import sharp from "sharp";

async function jpgToPng(inPath, outPath) {
  await sharp(inPath)
    .png({ compressionLevel: 9 })
    .toFile(outPath);
}

await jpgToPng("photo.jpg", "photo.png");

For batch jobs, fire off many at once — sharp pipelines are async and libvips multi-threads internally:

import { glob } from "glob";
import path from "node:path";

const jpgs = await glob("./jpg_in/*.jpg");
await Promise.all(
  jpgs.map((p) => {
    const out = `./png_out/${path.basename(p, ".jpg")}.png`;
    return sharp(p).png({ compressionLevel: 9 }).toFile(out);
  })
);

compressionLevel ranges 0-9 (default 6). Level 9 is slowest but smallest; level 6 is the balanced default; level 0 is fastest but largest.

Method 3: ChangeThisFile API (edge runtimes, no bundle hit)

For Cloudflare Workers, Vercel Edge, or any runtime where you can't load native bindings, the API is one fetch. Free tier covers 1,000 conversions/month.

const API_KEY = "ctf_sk_your_key_here";

async function jpgToPng(jpgBuffer, filename = "image.jpg") {
  const form = new FormData();
  form.append("file", new Blob([jpgBuffer], { type: "image/jpeg" }), filename);
  form.append("source", "jpg");
  form.append("target", "png");

  const response = await fetch("https://changethisfile.com/v1/convert", {
    method: "POST",
    headers: { Authorization: `Bearer ${API_KEY}` },
    body: form,
  });

  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  return await response.arrayBuffer();
}

// Cloudflare Worker example:
export default {
  async fetch(request) {
    const jpg = await request.arrayBuffer();
    const png = await jpgToPng(new Uint8Array(jpg));
    return new Response(png, {
      headers: { "Content-Type": "image/png" },
    });
  },
};

The API runs sharp server-side, so output is identical to method 2 without you needing to ship a 30MB native dependency.

When to use each

ApproachBest forTradeoff
Canvas API (browser)Pure-browser apps, zero server cost, privacyMain thread blocking on big images (use OffscreenCanvas)
sharp (Node)Server-side batch, max performanceNative dep — 30MB install, Lambda quirks
ChangeThisFile APIEdge runtimes, small bundlesNetwork call, file size limit (25MB free)

Production tips

  • Use Web Workers for browser conversions of big images. A 4K JPG can take 100-200ms to encode as PNG — long enough to drop frames. Move to a worker with OffscreenCanvas.
  • For sharp in Lambda, use the layer. Don't bundle sharp into your Lambda zip — use the public sharp Lambda layer for the right architecture binary.
  • compressionLevel=6 is the right default. Level 9 saves ~10% file size for 5x the encode time. Only worth it for static assets you serve repeatedly.
  • Strip JPG metadata. JPG often has GPS coordinates (iPhone photos), camera serial numbers, etc. sharp's default strips most of this; pass .withMetadata() to keep it.
  • Watch for CMYK JPGs. Print-prep JPGs can be CMYK. sharp converts CMYK to sRGB transparently; Canvas API may produce wrong colors. Test your inputs.

Pick the method that matches your runtime. All produce identical output because PNG is lossless — there's no quality difference between methods. Free tier covers 1,000 conversions/month.