Three image-processing problems I kept hitting in Node.js (and the library I built to fix them)

rust dev.to

Over the past couple of years I've shipped enough user-upload pipelines, thumbnail generators, and ML inference services in Node to have a strong opinion about image libraries in this ecosystem:

sharp is excellent. It's also not always enough.

This isn't a sharp takedown. sharp is one of the best-engineered libraries in the Node ecosystem and most projects should keep using it. But I kept running into a few specific problems where the workarounds felt worse than the original problem. So I built imgkit — a native Rust image library for Node.js and Bun — to handle them natively.

Here are the three problems, the hacks I used to live with, and what imgkit does instead.

Problem 1: A user uploads an iPhone photo and your pipeline explodes

If your product accepts user uploads — profile pictures, product photos, receipts, ID verification — you've hit this. iPhones have been shooting HEIC by default since iOS 11, and the default sharp binary on npm doesn't decode HEIC.

The usual workarounds:

  • Compile libvips from source with libheif enabled (fun on Alpine, less fun on your CI matrix)
  • Shell out to heif-convert in a subprocess
  • Force the client to transcode to JPEG before upload (lossy, slow on old phones)

None of these spark joy when you're on a Linux container and you just want the thing to work.

With imgkit on macOS ARM64, HEIC decoding is native and the whole pipeline is one call:

import { transform } from 'imgkit';

// Express/Fastify/Hono handler
app.post('/upload', async (req) => {
  const heicBuffer = Buffer.from(await req.file.arrayBuffer());

  // Decode HEIC, resize, convert to WebP — all in native Rust
  const webp = await transform(heicBuffer, {
    resize: { width: 1200 },
    output: { format: 'WebP', webp: { quality: 85 } }
  });

  await uploadToS3(webp, { contentType: 'image/webp' });
  return { ok: true };
});
Enter fullscreen mode Exit fullscreen mode

Upfront caveat: HEIC decoding in imgkit is currently macOS ARM64 only. If your production runs on Linux x64, this one isn't solved for you yet — it's on the roadmap. But if you're on a Mac mini dev server or running a Mac-based CI for image processing, this is already the easiest HEIC story in Node.

Problem 2: Progressive image loading without a separate thumbhash dependency

You've seen those dashboards where images fade in from a blurred placeholder instead of popping from grey. The two real options are blurhash (more common, ~30 bytes) and thumbhash (newer, smaller, better edge fidelity, typically ~25 bytes).

To generate these in Node today, you usually pull in a separate pure-JS package (thumbhash or blurhash-node), decode the image twice (once for the hash, once for the real output), and live with the CPU cost on every upload.

imgkit generates the placeholder and the thumbnail from the same decode:

import { thumbhash, resize } from 'imgkit';

async function processUpload(buffer: Buffer) {
  // One decode, two outputs
  const [{ dataUrl, hash }, thumbnail] = await Promise.all([
    thumbhash(buffer),
    resize(buffer, { width: 400 })
  ]);

  const uploaded = await uploadToS3(thumbnail);

  await db.images.create({
    placeholder: dataUrl,        // e.g. "data:image/png;base64,iVBOR..."
    thumbhash: hash,             // compact bytes for DB storage
    url: uploaded.url
  });
}
Enter fullscreen mode Exit fullscreen mode

On the client (React example):

function ProductImage({ url, placeholder }) {
  const [loaded, setLoaded] = useState(false);
  return (
    <div style={{ position: 'relative' }}>
      {!loaded && (
        <img
          src={placeholder}
          style={{ position: 'absolute', inset: 0, filter: 'blur(8px)' }}
        />
      )}
      <img
        src={url}
        onLoad={() => setLoaded(true)}
        loading="lazy"
      />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The placeholder is under 100 bytes, ships inline in your API response, and renders instantly. No layout shift, no grey flash, no extra dependency.

Problem 3: Image → tensor for ML inference

This is the one that surprised me the most. If you're running image classification, object detection, or embedding in Node — onnxruntime-node, @tensorflow/tfjs-node, or a custom model served via Fastify — the image preprocessing step is almost always slower than the model inference itself.

The typical pipeline:

  1. Decode image with sharp → raw RGBA buffer
  2. Resize to model input dimensions
  3. Manually reshape from HWC to CHW
  4. Manually normalize with ImageNet or CLIP mean/std
  5. Feed into the model

Steps 3 and 4 are where you end up writing for loops over millions of pixels in JavaScript. Even with TypedArrays it's slow, and it's code that everyone rewrites slightly wrong every time.

imgkit does the whole thing in native SIMD, with ImageNet and CLIP normalization constants baked in:

import { toTensor } from 'imgkit';
import { InferenceSession, Tensor } from 'onnxruntime-node';

const session = await InferenceSession.create('./resnet50.onnx');

async function classify(imageBuffer: Buffer) {
  // One call: decode → resize → normalize → NCHW layout
  const tensorData = await toTensor(imageBuffer, {
    width: 224,
    height: 224,
    normalization: 'Imagenet',
    layout: 'Chw',
    batch: true
  });

  // Shape: [1, 3, 224, 224]
  const input = new Tensor('float32', tensorData, [1, 3, 224, 224]);
  const output = await session.run({ input });

  return output;
}
Enter fullscreen mode Exit fullscreen mode

Running CLIP instead? Swap normalization: 'Clip' and you're done.

As far as I can tell, this is the only JS package that does native SIMD-accelerated image-to-tensor with built-in normalization constants. The usual alternative is roughly 40 lines of nested loops that subtly differ between every project that writes them.

Benchmarks (the honest version)

On an M1 Pro running Bun 1.3.3:

Operation imgkit sharp Speedup
Full transform pipeline 12.2ms 19.1ms 1.6×
1MB JPEG → 800px resize 12.6ms 20.3ms 1.6×
200px thumbnail 8.8ms 10.7ms 1.2×
WebP resize 1600×1200 → 200px 6.4ms 8.0ms 1.24×
50 concurrent ops 62ms 160ms 2.6×

I'm deliberately not quoting the 950× metadata number you'll see on the landing page — that's a header-only parse against sharp's more complete path, and it's not apples-to-apples. The numbers above are the ones I trust and can defend.

The concurrency number is the most practically interesting. If you're running a batch thumbnail generator or processing an upload queue, 2.6× at 50 concurrent operations translates to real infrastructure cost savings — roughly the difference between one worker and three.

When to use imgkit vs sharp

Stick with sharp if:

  • You need battle-tested, decade-plus production reliability
  • You need HEIC on Linux today (sharp + custom libvips build works)
  • You use features imgkit doesn't have yet (SVG rendering, some advanced filters, animated WebP encode)

Try imgkit if:

  • You accept iPhone uploads and you're on macOS ARM64
  • You're building an ML pipeline in Node and preprocessing is your bottleneck
  • You want thumbhash/blurhash without another dependency
  • You're on Bun and want a library that treats Bun as a first-class target

Install and links

bun add imgkit
# or
npm install imgkit
Enter fullscreen mode Exit fullscreen mode

Prebuilt binaries ship for macOS (ARM64 + x64), Linux x64 (glibc + musl/Alpine), Linux ARM64, Windows x64, and Windows ARM64. HEIC support is macOS ARM64 only for now.

Feedback, issues, and PRs are all wanted — especially if you hit an edge case my benchmarks didn't cover. Drop them on GitHub.

Source: dev.to

arrow_back Back to Tutorials