DEV Community

Cover image for Building an Image Optimization Pipeline in Node.js with ShortPixel
Bianca Rus
Bianca Rus

Posted on

Building an Image Optimization Pipeline in Node.js with ShortPixel

Images are still one of the easiest ways to accidentally slow down an app.

You can lazy-load them. You can put them behind a CDN. You can ask users to upload smaller files. But if your app accepts uploads, imports product photos, or handles any kind of dynamic media, sooner or later you need an image pipeline.

Not a manual checklist.

A pipeline.

Something that takes an image in, optimizes it, optionally creates a modern format like WebP or AVIF, and gives you back a file your app can store, serve, or push to a CDN.

In this article, we'll build that in Node.js using the official ShortPixel SDK. We'll start with a single local file, then move to batches, and finally wire the same logic into an Express upload route.

What you're working with

The ShortPixel API has two endpoints under the hood: one for remote URLs (the Reducer) and one for local files and buffers (the Post-Reducer). The Node SDK hides that split. You hand it whatever you have, a path, a URL, a Buffer, a list of any of those and it figures out the rest, including polling, retries, and pulling the result back down.

Install it:

npm i @shortpixel-com/shortpixel
Enter fullscreen mode Exit fullscreen mode

The package is ESM-only, so your package.json needs:

{
  "type": "module"
}
Enter fullscreen mode Exit fullscreen mode

Node 20+ is the safe target. You'll also need a ShortPixel API key which you can get for free by creating an account here. The free tier is enough for testing.

The smallest thing that works

Here's the smallest possible script that takes a local PNG, optimizes it, and writes the result next to your project:

import SHORTPIXEL from "@shortpixel-com/shortpixel";

const { ShortPixelClient } = SHORTPIXEL;

const cli = new ShortPixelClient({
  apiKey: process.env.SHORTPIXEL_API_KEY,
});

const src = await cli.optimize("assets/panda.png", {
  lossy: 1,
  convertto: "+webp",
});

await src.downloadTo("./output");
Enter fullscreen mode Exit fullscreen mode

A few things are happening here that are worth naming.

optimize() is a generic dispatcher. You can pass it a path string, a URL string, a Buffer, or arrays of those, and it routes to the right endpoint. lossy: 1 enables lossy optimization. Use 0 for lossless and 2 for glossy. convertto: "+webp" tells the API to return both the original format optimized and a WebP version, the + is what keeps the original in the output. Drop the + and you'd only get WebP back.

downloadTo() writes whatever the API returned into a local folder. That's the whole loop.

Feature-first helpers when you don't want to remember flags

The raw API parameters are fine, but in a real codebase you'll forget what resize: 4 means three months from now. The SDK ships intent-named helpers for the common operations:

// Upscale a small product photo to 2x
await cli.upscale("product.jpg", 2, { convertto: "+webp" });

// Fit inside a 1024x768 box
await cli.rescale("hero.jpg", 1024, 768);

// Smart-crop to an exact 300x200 thumbnail
await cli.smartCrop("avatar.jpg", 300, 200, { convertto: "+webp" });

// Remove the background, return a transparent WebP
await cli.backgroundRemove("shoe.jpg", { convertto: "+webp" });

// Replace the background with a solid color
await cli.backgroundChange("shoe.jpg", "#00ff0080", { convertto: "+webp" });
Enter fullscreen mode Exit fullscreen mode

Each helper is a thin wrapper that sets one or two parameters for you and forwards the rest. Anything you pass in options overrides the helper's defaults, so you can keep mixing capabilities — upscale and convert and keep EXIF, in a single call.

Batches

Single files are the easy case. The interesting one is batches, because that's where naive code starts hammering the API one request at a time. The SDK handles this for you:

const src = await cli.fromFiles(
  ["assets/a.png", "assets/b.png", "assets/c.png"],
  { lossy: 1, convertto: "+webp" }
);

await src.downloadTo("./output");
Enter fullscreen mode Exit fullscreen mode

Same thing works with URLs (fromUrls) and Buffers (fromBuffers). The SDK polls the API until every item in the batch is ready, then downloads them in one pass. If one item fails, you get a ShortPixelBatchError with per-item details instead of one cryptic message for the whole batch.

For long-running operations like background removal on a big batch, bump the polling budget:

cli.set("poll", {
  enabled: true,
  interval: 2000,
  maxAttempts: 30,
});
Enter fullscreen mode Exit fullscreen mode

That's it for the SDK side. Now the part that actually makes this a pipeline.

Hooking it into Express

If you're building an app, the images you care about usually arrive as uploads. The SDK ships an Express middleware that turns the whole flow above into something you mount once:

import express from "express";
import multer from "multer";
import { ShortPixelExpress } from "@shortpixel-com/shortpixel";

const app = express();
const upload = multer({ storage: multer.memoryStorage() });

app.post(
  "/upload",
  upload.single("image"),
  ShortPixelExpress({
    apiKey: process.env.SHORTPIXEL_API_KEY,
    lossy: 1,
    convertto: "+webp",
  }),
  (req, res) => {
    // req.file.buffer is already the optimized image
    // req.shortPixel.files[0] has the full result with metadata
    res.json({
      filename: req.file.originalname,
      size: req.file.buffer.length,
    });
  }
);
Enter fullscreen mode Exit fullscreen mode

The middleware reads req.file, req.files, and req.body, figures out what's an upload versus a remote URL versus a local path, runs each through the right SDK call, and then mutates the request in place. By the time your route handler runs, req.file.buffer is already the optimized version, no extra await, no manual download step.

It also exposes req.shortPixel with normalized results for each input, so you can grab metadata, the output filename, or the raw buffer when you need to push to S3, a CDN, or wherever your storage lives.

If you'd rather mount it globally and let unmatched routes pass through untouched, set passthrough: true on the middleware options.

A note on what to expect

A pipeline like this is mostly invisible when it works. You drop it in, your upload route returns smaller files, your storage footprint goes down, and your pages get faster without you touching the frontend. The interesting parts are the edges: what happens when an upload is malformed, when a URL is unreachable, when the API is briefly down. The SDK uses typed errors (ShortPixelAuthError, ShortPixelQuotaError, ShortPixelTemporaryError, ShortPixelInvalidRequestError, ShortPixelBatchError) so you can branch on the failure mode instead of parsing error strings.

Wrap the whole thing in a try/catch, log err.spCode and err.httpStatus, and you have something you can ship.

Where to go next

This is intentionally a small pipeline, but the same structure scales pretty well.

From here, you could:

  • push the optimized buffer to S3
  • store image metadata in your database
  • generate both WebP and AVIF versions
  • run uploads through a queue instead of doing everything inside the request
  • add per-user or per-project optimization settings

The important part is that image optimization becomes part of the backend flow instead of a manual cleanup task later.

If your app accepts images, optimizing them at upload time is usually the cleanest place to start.

Links

Top comments (0)