Sitemap

Cut Out Products at Upload in Node.js, and Fall Back Cleanly When a Photo Fails

8 min read1 hour ago

--

Press enter or click to view image in full size
Cut out the product at upload, fall back cleanly: save the original, POST to the API, check status.code and the alpha, publish main.jpg on white or send the original to a review list

Sellers upload product photos from kitchens, bedrooms and garden tables. A marketplace wants the main image of every listing on a plain background, so the obvious move is to cut the product out the moment the photo arrives.

Get API4AI’s stories in your inbox

Join Medium for free to get updates from this writer.

The less obvious part is what happens when the cut-out goes wrong, or doesn’t come back at all. This post builds an upload hook in Node.js that:

  1. keeps the seller’s original, always;
  2. sends the photo to a background removal API;
  3. checks the result with a few cheap tests;
  4. publishes a 2000×2000 white-background main image only if everything passed, and otherwise publishes the original and puts the photo on a review list.

The seller never waits for a person, and a bad cut-out never reaches the listing page. It’s about 130 lines: one module with the logic, and an Express route that uses it. (We make the API used here, the API4AI Background Removal API. The shape of the hook works with any of them.)

Setup

Node 18 or newer (for the built-in fetch, FormData and Blob), plus three packages:

npm install express multer sharp
export API4AI_KEY=... # from portal.api4.ai

sharp does the image work: reading the alpha channel, trimming, placing the product on white.

The lesson first: HTTP 200 can still be a failure

When the service can’t process a photo (say, the file isn’t really an image), it doesn’t answer with a 4xx. It answers HTTP 200, and the JSON says so:

{
"results": [
{ "status": { "code": "failure", "message": "Can not load image." }, "entities": [] }
]
}

Code that checks only res.ok reads that as a success with no picture in it, and your listing gets an empty image, or a crash three functions later. So the hook sorts every outcome into one of three buckets:

Press enter or click to view image in full size

The module

// cutout-hook.mjs: cut the product out of a seller's upload, or fall back to the original.
// npm install sharp
// export API4AI_KEY=... (from portal.api4.ai)
import sharp from "sharp";

const API = "https://api4ai.cloud/img-bg-removal/v1/results";

// 1. Call the API. Retry what can succeed later; report what can't.
export async function cutout(photo, filename, tries = 3) {
for (let attempt = 1; ; attempt++) {
const form = new FormData();
form.append("image", new Blob([photo]), filename);
let res;
try {
res = await fetch(API, {
method: "POST",
headers: { "X-API-KEY": process.env.API4AI_KEY },
body: form,
signal: AbortSignal.timeout(60_000),
});
} catch (err) { // network error or timeout
if (attempt < tries) { await sleep(attempt); continue; }
return { ok: false, retryLater: true, reason: `network: ${err.message}` };
}
if ((res.status === 429 || res.status >= 500) && attempt < tries) {
await sleep(attempt);
continue;
}
if (res.status !== 200) {
return { ok: false, retryLater: res.status === 429 || res.status >= 500,
reason: `HTTP ${res.status}: ${(await res.text()).slice(0, 200)}` };
}
const result = (await res.json()).results[0];
// The lesson: a photo the service can't process still comes back as HTTP 200.
if (result.status.code !== "ok") {
return { ok: false, retryLater: false, reason: result.status.message };
}
const image = result.entities.find((e) => e.kind === "image");
return { ok: true, png: Buffer.from(image.image, "base64") };
}
}

const sleep = (attempt) => new Promise((r) => setTimeout(r, 1000 * 2 ** (attempt - 1)));

// 2. Cheap checks on the cut-out: flag the ones a person should look at.
export async function checkCutout(png) {
const { data, info } = await sharp(png).ensureAlpha().extractChannel(3)
.raw().toBuffer({ resolveWithObject: true });
const { width, height } = info;
let kept = 0, soft = 0, edge = 0;
for (let i = 0; i < data.length; i++) {
const a = data[i];
if (a > 127) kept++;
if (a > 25 && a < 230) soft++; // clearly see-through
const x = i % width, y = (i / width) | 0;
if (a > 127 && (x === 0 || y === 0 || x === width - 1 || y === height - 1)) edge++;
}
const n = width * height;
const flags = [];
if (kept / n < 0.03) flags.push("almost nothing kept");
if (kept / n > 0.95) flags.push("almost nothing removed");
if (edge / (2 * (width + height)) > 0.10) flags.push("touches the frame");
if (soft / Math.max(kept, 1) > 0.05) flags.push("lots of see-through pixels");
return { keptShare: +(kept / n).toFixed(3), flags };
}

// 3. The listing image: product trimmed, centred on a white square with a margin.
export async function toListing(png, size = 2000, fill = 0.85) {
const trimmed = await sharp(png).trim().toBuffer();
const inner = Math.round(size * fill);
const product = await sharp(trimmed)
.resize(inner, inner, { fit: "inside" })
.toBuffer();
return sharp({ create: { width: size, height: size, channels: 3,
background: "#ffffff" } })
.composite([{ input: product, gravity: "centre" }])
.jpeg({ quality: 90 })
.toBuffer();
}

// 4. The hook: always keep the original; publish the cut-out only when it passed.
export async function onSellerUpload(photo, filename) {
const cut = await cutout(photo, filename);
if (!cut.ok) {
return { main: photo, cutout: null,
review: cut.retryLater ? "retry later" : "needs a person",
reason: cut.reason };
}
const check = await checkCutout(cut.png);
if (check.flags.length) {
return { main: photo, cutout: cut.png, review: "needs a person",
reason: check.flags.join(", ") };
}
return { main: await toListing(cut.png), cutout: cut.png, review: null };
}

What each part does, and the choices you may want to change:

  • cutout() sends the uploaded bytes as the multipart field image. The PNG comes back as base64 inside the JSON, with a transparent background, at the size of the input. The status.code check is the line not to drop.
  • checkCutout() reads only the alpha channel, so it's fast. Four flags:
  • almost nothing kept (under 3% of the frame): no product found, or the photo is a screenshot, a label or a blank;
  • almost nothing removed (over 95%): the background was kept, or the product fills the frame;
  • touches the frame (over 10% of the border): the seller cropped the product, or part of the table survived;
  • lots of see-through pixels: glass, clear plastic, lace, or a blurry photo.
  • toListing() trims the transparent border, scales the product to fit 85% of a 2000×2000 square and centres it on white. Change size and fill to your marketplace's rules.
  • onSellerUpload() puts it together. It always returns something to publish as main: the white-background image if all went well, the seller's original if not.

The thresholds are starting points, not truths. On our three demo photos (red suede shoes, a sports car, a person on a street), between 2.4% and 3.9% of the kept pixels were clearly see-through, so 5% passed all three, while a glass bottle or a lace dress should land well above it. Run a day of your own uploads through checkCutout(), look at what it flags, and move the numbers.

The route

// server.mjs: the hook behind an upload route.
// npm install express multer sharp
import express from "express";
import multer from "multer";
import fs from "node:fs/promises";
import path from "node:path";
import { onSellerUpload } from "./cutout-hook.mjs";

const app = express();
const upload = multer({ storage: multer.memoryStorage(),
limits: { fileSize: 16 * 1024 * 1024 } });

app.post("/listings/:id/photos", upload.single("photo"), async (req, res) => {
const original = req.file.buffer;
await save(`${req.params.id}/original-${req.file.originalname}`, original);

const out = await onSellerUpload(original, req.file.originalname);
// main.jpg is the white-background version; on a fallback, the original.
const ext = out.review ? path.extname(req.file.originalname) : ".jpg";
await save(`${req.params.id}/main${ext}`, out.main);
if (out.cutout) await save(`${req.params.id}/cutout.png`, out.cutout);
if (out.review) await queueForReview(req.params.id, out.review, out.reason);

res.json({ listing: req.params.id, cutout: !out.review, review: out.review,
reason: out.reason ?? null });
});

// Replace these two with your storage and your review queue.
async function save(key, buf) {
await fs.mkdir(path.dirname(`uploads/${key}`), { recursive: true });
await fs.writeFile(`uploads/${key}`, buf);
}
async function queueForReview(id, what, why) {
console.log(`review ${id}: ${what} (${why})`);
}

app.listen(3000, () => console.log("listening on :3000"));

Start it and upload a photo:

node server.mjs
curl -F photo=@shoes.jpg http://localhost:3000/listings/A100/photos

What it did with our test photos

Press enter or click to view image in full size
Press enter or click to view image in full size

Two of those rows are the reason the hook exists. The text file would have looked like a success to a plain res.ok check, and the blank came back as a perfectly valid, perfectly empty PNG. Neither reached the listing.

Before you put it in production

  • Move it off the request path when uploads come in bursts. As written, the seller waits several seconds for the API (4 to 12 s per photo in our test). With a queue (BullMQ, SQS, whatever you run), save the original, answer at once, and swap in the white-background image when the job finishes.
  • Retry “retry later” from the queue, not from the request. Two quick retries cover a blip; a longer outage should wait minutes, not seconds.
  • Cut out the main image only. Detail shots and photos of the product in use need their context, and for used items the original photo shows the condition.
  • A white background isn’t the whole of a marketplace’s rules. Sizes, borders, text and watermarks are rules too, and a cut-out doesn’t remove text a seller added to the photo.
  • Keep the original forever. You will change the thresholds, the size or the margin, and you’ll want to re-run the old uploads.

Cost

Background Removal is $5 per 1,000 images on the API4AI developer portal, pay as you go with no subscription; new accounts get $5 of free credit (no credit card), and the balance never expires. At 10,000 uploads a month that’s $50 for the API. What decides your real cost is how many photos land on the review list, so count that in the first week.

If your hook calls remove.bg today: remove.bg’s self-serve API closes on December 1, 2026, and our remove.bg-compatible endpoint takes the same requests. New URL and key, same code: send them to https://api4ai.cloud/img-bg-removal/v1/rbg/v1.0/removebg with your API4AI key in the X-Api-Key header. Prices per image, the same for every size: $0.005 for general background removal (type=auto included), $0.015 with type=person, $0.03 with type=car; for people and cars, set the type for the best results. The details are on our migration page.

The guide this tutorial goes with, on where an automatic cut-out fits in a seller upload flow and where it doesn’t: Remove backgrounds from seller uploads automatically.

What does your platform do today when a seller’s photo can’t be processed?

--

--

API4AI
API4AI

Written by API4AI

We build AI-powered cloud solutions to empower products and businesses with computer vision and machine learning.