Skip to content

Previews and Performance

Abdulkader Safi edited this page Jul 16, 2026 · 1 revision

Previews and Performance

This is the page that matters if you are building anything interactive. It is the reason the library exists, and it contains one trap that will silently cost you 1.6×.

The problem

A 12MP photo is 4000×3000. Decoding it fully means inverse-transforming 187,500 blocks and allocating 48 MB. Then rotating it resamples all 12 million pixels. Measured, on a 12MP JPEG:

Time
decode() at full resolution 224ms
crop + rotate at full resolution ~150ms

Do that on every frame of a slider drag and you have a slideshow. That measurement — a ~260ms full-resolution rotate causing visible stutter — is what started this project.

The answer: two passes

Do not make the rotate faster. Stop doing it at full resolution sixty times a second.

// 1. On open: decode ONCE, small. Hold it.
const preview = await decode(bytes, { hintMaxLongEdge: 1600 });

// 2. On every interaction: transform THAT. No re-decode, no encode.
const frame = rotate(crop(preview, rect), angle);
// push `frame` at a canvas

// 3. On save: full resolution, once. The user hit save; 600ms is fine.
const saved = await SImg.pipeline(spec).run(bytes);

Measured, same photo:

Time
decode(bytes, { hintMaxLongEdge: 1600 }) 37ms 1000×750
crop + rotate on the preview ~10ms inside a 16ms frame
the same frame at full resolution 153ms what it replaces

Run it yourself: npm run example:preview.

Step 2 never encodes. Worth saying twice: the preview loop is decode-once, transform-per-frame. A preview that re-encodes a JPEG every frame would be slower than the problem it is fixing. toBuffer() is not in the loop.

Why the decode is actually faster

JPEG stores 8×8 blocks of frequency coefficients. To decode at half size you inverse-transform only the top-left 4×4 of each block — at a quarter, the 2×2; at an eighth, just the DC coefficient, which is the block's mean.

The transform gets cheaper, not skipped. That is the difference between this and decode-then-resize, which pays the full cost and then adds a resize on top.

There is an honest ceiling: the entropy decode does not get faster. JPEG's Huffman stream has to be walked in full whatever size you want out of it, and at 1/8 scale it is most of what is left. That is why 224ms → 37ms rather than → 4ms.

The trap

hintMaxLongEdge is a hint. Read the real size off the result. Never compute it.

DCT scaling only does powers of two. So a 4000px photo hinted at 1600 comes back at 1000, not 1600 — because 1/2 would give 2000, which is over the cap, so it takes 1/4.

const preview = await decode(bytes, { hintMaxLongEdge: 1600 });
preview.width; // 1000. Not 1600.

Now map a crop rectangle the user drew on the preview back to source coordinates:

// WRONG — off by 1.6×, silently, on every JPEG
const scale = 4000 / 1600; // 2.5

// RIGHT
const scale = probe(bytes).width / preview.width; // 4

Both produce a plausible crop. One produces the wrong region. Nothing throws, no test you did not write for it fails, and the bug only shows up as "the saved image is not quite what I selected".

probe() exists for exactly this: real source dimensions in microseconds, no decode.

The contract is at or under the hint, never over — for every format, including the fallback path. So a hint of 200 on a 4000px JPEG gives you 200 (the DCT gets to 500, then a resize finishes the job).

What each format can do

The win varies enormously, and pretending otherwise would be dishonest:

Format Native downsample
JPEG yes, big win DCT scaling. Nearly free, and the format that matters most
PNG no DEFLATE is a byte stream; you cannot inflate a quarter of it
GIF no LZW, same problem
WebP no not exposed through the WASM build used here
BMP partial uncompressed, so rows can be skipped
TIFF partial same, when uncompressed

For the rest, decode(bytes, { hintMaxLongEdge }) decodes fully, resizes, and releases the full-resolution buffer. The decode itself is not faster. You still get a small image, and you still save on every downstream transform, which is where most of the cost is anyway.

One honest limitation: the fallback still allocates the full 48 MB before downsampling. On a memory-constrained machine, opening a huge PNG preview costs the same as opening it fully.

The same spec for both passes

The trick that keeps the preview and the save honest is that they run the same object:

const spec = SImg.pipeline()
  .crop({ x: drawn.x * scale, y: drawn.y * scale, width: drawn.width * scale, height: drawn.height * scale })
  .rotate(20)
  .toFormat('jpeg', { quality: 88 })
  .toJSON();

await SImg.pipeline(spec).run(bytes); // full resolution

There is no second code path to drift out of sync with the first.

Choosing a resampling kernel

Kernel Use it for
nearest pixel art, or when you want speed and do not care
bilinear the default, and the right choice for a preview
lanczos3 a final save, where sharpness matters and 6 taps per axis is affordable

Bilinear on the preview path, lanczos3 on save, is a reasonable default pair.

Other numbers

npm run bench:preview   # the whole preview path, on a generated 12MP JPEG

Timings are from an M-series laptop and are directional, not a contract. The contract is an instrumented counter in the test suite proving the reduced transform actually runs — a stopwatch test would be flaky, and that fact is not.

Clone this wiki locally