Image

Decode, transform, and encode images with a fast native pipeline

Bun.Image is a chainable image pipeline for decoding, resizing, rotating, and re-encoding JPEG, PNG, WebP, HEIC, and AVIF. It is built on libjpeg-turbo, libwebp, a built-in PNG codec, and SIMD geometry kernels, with zero npm dependencies and no native addon build step.

await Bun.file("photo.jpg").image().resize(400, 400, { fit: "inside" }).webp({ quality: 80 }).write("thumb.webp");

The API is shaped after Sharp: construct from an input, chain transforms, pick an output format, then await a terminal method. Nothing runs until you await the terminal, and the work executes off the JavaScript thread.

Input#

The constructor accepts a path, bytes, or a Blob — including Bun.file() and Bun.s3.file(). Blob#image() is shorthand for new Bun.Image(blob):

new Bun.Image("./photo.jpg"); // file path
new Bun.Image(buffer); // Buffer / ArrayBuffer / TypedArray
new Bun.Image(Bun.file("photo.jpg")); // BunFile (read lazily, off-thread)
Bun.file("photo.jpg").image(); // same as above
Bun.s3.file("bucket/photo.jpg").image(); // S3File

Bun sniffs the format from the bytes and ignores extensions and Content-Type.

Path strings are filesystem paths. Don't pass user-controlled strings directly to the constructor — that's an arbitrary-file-read primitive. Read untrusted input into a Buffer (with fetch or Bun.file and your own validation) and pass the bytes.

When passing a TypedArray/ArrayBuffer, don't mutate it while a terminal is pending — decode runs off-thread and borrows the bytes. Bun refuses SharedArrayBuffer and resizable buffers; use buf.slice() to pass a fixed view.

Bun decodes a damaged JPEG when libjpeg-turbo can finish the decode with only a warning. Examples are stray bytes between segments, a missing end-of-image marker, and truncated scan data. libjpeg-turbo fills the blocks that have no data with flat grey. A progressive file that is cut after its first scan has data for every block, so Bun returns the whole picture with less detail. When libjpeg-turbo cannot finish the decode, the terminal rejects with ERR_IMAGE_DECODE_FAILED. metadata() reads only the header, so it can resolve for a file that a terminal rejects.

Bun does not verify the checksums in a PNG. It skips the CRC of each chunk and the Adler-32 of the pixel data, so a file with damaged bytes can decode to different pixels. Bun reads IDAT chunks until one of them completes the last row, and ignores everything after the data of that chunk. When the file ends before the data of that chunk does, the terminal rejects with ERR_IMAGE_DECODE_FAILED. The terminal also rejects when the pixel data ends before the last row.

A second options argument guards against decompression bombs and controls EXIF handling:

new Bun.Image(input, {
  // Reject if width*height > this. Checked after reading the header,
  // before allocating the pixel buffer. Default matches Sharp (~268 MP).
  maxPixels: 4096 * 4096,
  // Apply JPEG EXIF Orientation before any other op. Default: true.
  autoOrient: true,
});

A decoded image takes 4 bytes per pixel, so the default maxPixels allows 1 GiB for one image. A PNG of 32 KiB can decode to that size. The decode needs more memory while it runs: up to 20 bytes per pixel for a PNG that is one very wide row, which is 5 GiB at the default. For untrusted input, set maxPixels to the largest image your application accepts.

Metadata#

Read width, height, and format without decoding pixel data:

const { width, height, format } = await new Bun.Image(input).metadata();
// => { width: 1920, height: 1080, format: "jpeg" }

Resize#

img.resize(800); // width 800, keep aspect ratio
img.resize(800, 600); // exactly 800×600 (stretch)
img.resize(800, 600, { fit: "inside" }); // fit within 800×600
img.resize(800, 600, { withoutEnlargement: true }); // never upscale
img.resize(800, 600, { filter: "mitchell" });
fitBehavior
"fill" (default)Stretch to exactly width × height
"inside"Preserve aspect ratio; result fits within the box

filter selects the resampling kernel. The default "lanczos3" is the right choice for photographs.

FilterUse when
"lanczos3" (default)General-purpose, sharpest for photos
"lanczos2"Slightly softer, fewer ringing artifacts
"mitchell"Smooth gradients; the classic bicubic compromise
"cubic"Catmull-Rom — sharper than Mitchell, can ring
"mks2013" / "mks2021""Magic Kernel Sharp"; used by Facebook/Instagram
"bilinear" / "linear"Fast, soft
"box"Area-average; good for large integer downscales
"nearest"Pixel art / hard edges

When the source is a JPEG and the target is at most half the source size, decode skips straight to the nearest M/8 IDCT scale. As a result, generating a thumbnail from a 24 MP photo never materializes the full-resolution buffer.

Rotate · flip#

img.rotate(90); // 90° clockwise (multiples of 90 only)
img.flip(); // mirror vertically (about the x-axis)
img.flop(); // mirror horizontally (about the y-axis)

Modulate#

img.modulate({
  brightness: 1.2, // 1 = unchanged
  saturation: 0, // 0 = greyscale, 1 = unchanged, >1 = boost
});

Output formats#

Calling a format method sets the encode target; without one, Bun reuses the source format.

img.jpeg({ quality: 85 }); // 1–100, default 80
img.png({ compressionLevel: 6 }); // zlib level 0–9
img.png({ palette: true, colors: 64, dither: true }); // indexed PNG
img.webp({ quality: 80 });
img.webp({ lossless: true });
img.heic({ quality: 80 }); // macOS / Windows only
img.avif({ quality: 60 }); // macOS / Windows only

palette: true quantizes to a ≤256-color palette and emits an indexed (color-type 3) PNG, optionally with Floyd–Steinberg dither. The indexed PNG is typically 3–5× smaller than truecolor for screenshots and UI assets.

Terminals#

A pipeline does no work until you await one of these:

await img.bytes(); // Uint8Array
await img.buffer(); // Buffer
await img.blob(); // Blob with .type set to the output MIME
await img.toBase64(); // string
await img.dataurl(); // "data:image/png;base64,…"
await img.write("out.webp"); // number (bytes written)
await img.write(Bun.s3.file("bucket/out.webp"));

.write() accepts the same destinations as Bun.write — a path string, Bun.file(), Bun.s3.file(), or an fd. If you didn't chain a format method and the destination is a path string, the extension picks one (.jpg/.png/.webp/.heic/.avif).

Placeholders#

.placeholder() returns a low-quality placeholder to inline in HTML before the real image loads. The placeholder is a ThumbHash-rendered ≤32px blur as a data: URL. It is ~400–700 bytes and needs no client-side decoder:

const lqip = await Bun.file("hero.jpg").image().placeholder();
// <img src={lqip} … /> — then swap to the real URL on load.

For coarse-to-fine rendering of the image itself, encode a progressive JPEG:

img.jpeg({ progressive: true });

After the first terminal resolves, img.width and img.height reflect the output dimensions (they're -1 before).

Bun.serve integration#

A Bun.Image pipeline is a valid Response body and sets Content-Type automatically. To keep the encode off the JS thread in a server handler, await a terminal first:

Bun.serve({
  routes: {
    "/avatar/:id": async req => {
      // Validate before touching the filesystem (see the Input note above).
      if (!/^[a-z0-9]+$/.test(req.params.id)) return new Response(null, { status: 400 });
      const out = await Bun.file(`avatars/${req.params.id}.png`).image().resize(128, 128).webp().blob();
      return new Response(out);
    },
  },
});

Passing the pipeline directly (new Response(img)) also works, but runs the encode synchronously during body init.

Clipboard#

const img = Bun.Image.fromClipboard();
if (img) {
  const png = await img.resize(800, 800, { fit: "inside" }).png().bytes();
}

fromClipboard() reads PNG, TIFF, HEIC, JPEG, WebP, GIF, or BMP from the system pasteboard on macOS and Windows; the regular decode pipeline takes it from there. Returns null if there's no image, and always null on Linux — call wl-paste/xclip yourself and pass the bytes to the constructor.

For a passive "image in clipboard, press ⌘V" hint, poll clipboardChangeCount() (a single integer read) and call hasClipboardImage() only when it moves. Polling is the documented pattern because macOS has no clipboard-change notification.

Platform backends#

LinuxmacOSWindows
JPEG / PNG / WebPlibjpeg-turbo · built-in · libwebpsamesame
BMP / GIF (decode)built-inImageIOWIC
TIFF (decode)❌ImageIOWIC
Resize / rotate / flipHighway SIMDAccelerate vImage ³Highway SIMD
HEIC / AVIF❌ ERR_IMAGE_FORMAT_UNSUPPORTEDImageIO ²WIC ¹
Clipboard❌ returns nullNSPasteboardWin32

¹ Windows requires the HEIF Image Extensions / AV1 Video Extension from the Microsoft Store. ² AVIF encode needs an OS AV1 encoder — Apple Silicon M3+ only. Intel Mac and M1/M2 reject with ERR_IMAGE_FORMAT_UNSUPPORTED; AVIF decode works everywhere ImageIO does (macOS 13+). ³ Resize uses vImage only with the default lanczos3 filter; every other filter uses Highway SIMD on macOS too.

When a system-backend format isn't available on the current machine, the terminal rejects with error.code === "ERR_IMAGE_FORMAT_UNSUPPORTED". Branch on that error code to fall back to a portable format:

const out = await img
  .avif({ quality: 50 })
  .bytes()
  .catch(e => {
    if (e.code === "ERR_IMAGE_FORMAT_UNSUPPORTED") return img.webp({ quality: 80 }).bytes();
    throw e;
  });

Formats handled by the system backend (TIFF, HEIC, AVIF, clipboard) inherit the OS's patch level, so keep macOS / Windows updated. JPEG, PNG, and WebP go through the same statically-linked codecs on every platform, so encoded output is byte-identical across Linux, macOS, and Windows. To force the portable Highway path for geometry too (e.g. for golden-image tests), set the process-global backend:

Bun.Image.backend = "bun"; // default is "system" on macOS/Windows