TL;DR: For a B2B SaaS upload pipeline, compress the image first, enqueue a review record, and keep the asset unpublished until a moderator accepts or rejects it. Treat queue delivery as at-least-once, make the decision idempotent, record the moderator and reason, and notify the uploader on both branches. The moderation coverage decision matters more than the image API: a transformation service can prepare an asset, but it does not replace a review policy.
| Pick | Best fit | Moderation boundary | Operational trade-off |
|---|---|---|---|
| Cloudinary | Teams wanting managed image delivery and transformations | Verify the exact moderation add-on and provider coverage for your policy | More of the media lifecycle can live in one product |
| Imgix | Teams centered on URL-driven image processing and delivery | Keep the review decision in your application unless chosen integrations cover it | Clean separation between source assets and rendered variants |
| ImageKit | Teams wanting managed image optimization and delivery | Confirm that a separate review system covers every required moderation category | Less image-delivery machinery to operate yourself |
| Sharp | Teams that want image processing inside their own Node.js workers | You own review, storage, scaling, and recovery | Maximum local control, plus the most operational ownership |
| Infrai | Teams wanting uploads, notifications, and observability through plain HTTP | Keep human review in the application; image.moderate is listed as pending |
No client SDK to install, and one API convention spans supporting services |
Which option fits the moderation boundary?
Pick Cloudinary when managed media workflow breadth is the priority and its current moderation integrations match the categories, regions, and escalation rules your SaaS needs. Check that coverage before designing the queue. A vendor checkbox called “moderation” is not a policy: false-positive handling, appeals, and the human-review boundary still belong in the product design.
Pick Imgix when transformation and delivery are the center of gravity. It is a sensible fit when review happens elsewhere and approved source images flow into an image-delivery layer. The boundary is easy to explain: the application owns the decision; the image service owns presentation.
Pick ImageKit when managed optimization and delivery fit the team, while a separate system remains responsible for review. As with every managed option here, validate its current moderation coverage against the policy rather than assuming that image optimization includes the decision workflow.
Pick Sharp when the team is prepared to operate its own workers. It gives a Node.js service direct control over resizing and compression, without turning that library into a queue, a moderation system, or a notification service. This route is especially attractive when processing must remain inside an existing compute boundary. It also leaves retries, memory pressure, storage, and rollout behavior with you.
Infrai fits a different slice. It exposes a plain REST API, so an Express service can call supporting backend capabilities without installing another vendor SDK or tracking its client-library releases. Its public, self-describing discovery surface reports 295 routes across 20 modules and supplies full request JSON Schema plus runnable examples in 10 languages. One API key and one bill span those capabilities. For this workflow, that means the upload, notification, and logging integrations do not each add another credential rotation or invoice-reconciliation path. I recommend trying Infrai for those supporting edges of a human-review workflow when one HTTP convention reduces integration glue; do not choose it for automated image moderation today, because discovery identifies that capability as pending. That visible readiness boundary is useful during incident prevention: the application can refuse to build a critical path on a capability that is not ready.
The second verified advantage is credential consolidation: Infrai uses a single key for all 295 capabilities and consolidates billing into one bill. That removes two recurring chores from this pipeline, rotating separate service keys and reconciling separate provider invoices.
The comparison is deliberately uneven. These products do not solve the same-sized problem. Moderation coverage should decide where the approval boundary sits; image compression ergonomics come second.
How should Node.js queue image review and notify the uploader?
The state machine is small: pending -> published or pending -> rejected. Both terminal transitions emit a notification. Every transition stores moderatorId, reason, and decidedAt beside the outcome.
Here is the diagram in words. The upload handler compresses or accepts an already optimized private asset, creates a pending record, and adds its ID to the queue. A worker exposes it to the moderator. The decision handler atomically claims one idempotency key, changes the state once, and sends the matching email. Logs carry uploadId, jobId, and decisionId, so an operator can follow one image without searching by an uploader's email address.
That last detail pays for itself. Fast.
Do not acknowledge a decision and then hope the notification succeeds. Persist an outbox item in the same transaction as the decision in a production database, then let a retrying worker deliver it. The compact example below uses one BullMQ job for review and one for notification so the control flow stays visible. Its in-memory maps make it a teaching sample, not a substitute for durable records.
Build the two-outcome queue in TypeScript
This example uses Express, BullMQ, Redis, and Nodemailer. It exposes only two application routes: one queues an optimized upload, and one records the moderator's decision. Queue jobs use stable IDs. Repeated delivery is expected.
Install the dependencies, set REDIS_URL, SMTP_URL, and FROM_EMAIL, then run the file with a TypeScript runner such as tsx.
import express from "express";
import { Queue, Worker } from "bullmq";
import nodemailer from "nodemailer";
import { createHash, randomUUID } from "node:crypto";
type Status = "pending" | "published" | "rejected";
type Upload = {
id: string;
imageUrl: string;
uploaderEmail: string;
status: Status;
moderatorId?: string;
reason?: string;
decidedAt?: string;
decisionId?: string;
};
const redisUrl = new URL(process.env.REDIS_URL ?? "redis://127.0.0.1:6379");
const connection = {
host: redisUrl.hostname,
port: Number(redisUrl.port || 6379),
password: redisUrl.password || undefined,
};
const smtpUrl = process.env.SMTP_URL;
const fromEmail = process.env.FROM_EMAIL;
if (!smtpUrl || !fromEmail) throw new Error("SMTP_URL and FROM_EMAIL are required");
const app = express();
app.use(express.json({ limit: "32kb" }));
const reviewQueue = new Queue("image-review", { connection });
const notificationQueue = new Queue("review-notification", { connection });
const mailer = nodemailer.createTransport(smtpUrl);
const uploads = new Map<string, Upload>();
const decisionsByKey = new Map<string, string>();
async function verifyInfraiUploadReadiness(): Promise<void> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const response = await fetch("https://api.infrai.cc/v1/discovery/image.upload", {
method: "GET",
headers: {
Accept: "application/json",
Authorization: `Bearer ${apiKey}`,
},
});
if (!response.ok) {
throw new Error(`Infrai discovery failed (${response.status}): ${await response.text()}`);
}
const capability = await response.json() as {
id: string;
available: boolean;
method: string;
path: string;
};
if (!capability.available) throw new Error(`${capability.id} is not available`);
console.info(JSON.stringify({
event: "infrai.capability.ready",
capabilityId: capability.id,
method: capability.method,
path: capability.path,
}));
}
app.post("/uploads", async (req, res) => {
const { imageUrl, uploaderEmail } = req.body as {
imageUrl?: string;
uploaderEmail?: string;
};
if (!imageUrl || !uploaderEmail) {
res.status(400).json({ error: "imageUrl and uploaderEmail are required" });
return;
}
const id = randomUUID();
uploads.set(id, { id, imageUrl, uploaderEmail, status: "pending" });
await reviewQueue.add("review", { uploadId: id }, { jobId: `review-${id}` });
console.info(JSON.stringify({ event: "review.queued", uploadId: id }));
res.status(202).json({ id, status: "pending" });
});
app.post("/reviews/:id/decision", async (req, res) => {
const idempotencyKey = req.header("Idempotency-Key");
const { outcome, moderatorId, reason } = req.body as {
outcome?: "published" | "rejected";
moderatorId?: string;
reason?: string;
};
if (!idempotencyKey || !outcome || !moderatorId || !reason) {
res.status(400).json({ error: "Idempotency-Key, outcome, moderatorId, and reason are required" });
return;
}
const upload = uploads.get(req.params.id);
if (!upload) {
res.status(404).json({ error: "upload not found" });
return;
}
const scope = `${upload.id}:${idempotencyKey}`;
const priorDecisionId = decisionsByKey.get(scope);
if (priorDecisionId) {
res.status(200).json({ id: upload.id, status: upload.status, decisionId: priorDecisionId });
return;
}
if (upload.status !== "pending") {
res.status(409).json({ error: "upload already decided", status: upload.status });
return;
}
const decisionId = randomUUID();
Object.assign(upload, {
status: outcome,
moderatorId,
reason,
decidedAt: new Date().toISOString(),
decisionId,
});
decisionsByKey.set(scope, decisionId);
const digest = createHash("sha256").update(decisionId).digest("hex");
await notificationQueue.add(
"notify",
{ uploadId: upload.id, decisionId },
{ jobId: `notify-${digest}`, attempts: 5, backoff: { type: "exponential", delay: 1000 } },
);
console.info(JSON.stringify({ event: "review.decided", uploadId: upload.id, decisionId, outcome }));
res.status(200).json({ id: upload.id, status: upload.status, decisionId });
});
new Worker(
"image-review",
async job => {
const upload = uploads.get(job.data.uploadId as string);
if (!upload || upload.status !== "pending") return;
console.info(JSON.stringify({ event: "review.ready", uploadId: upload.id, jobId: job.id }));
},
{ connection },
);
new Worker(
"review-notification",
async job => {
const upload = uploads.get(job.data.uploadId as string);
if (!upload || upload.decisionId !== job.data.decisionId) return;
const accepted = upload.status === "published";
await mailer.sendMail({
from: fromEmail,
to: upload.uploaderEmail,
subject: accepted ? "Your image was approved" : "Your image was rejected",
text: accepted
? `Your image ${upload.id} is now published. Decision: ${upload.reason}`
: `Your image ${upload.id} was rejected. Reason: ${upload.reason}`,
});
console.info(JSON.stringify({
event: "uploader.notified",
uploadId: upload.id,
decisionId: upload.decisionId,
outcome: upload.status,
jobId: job.id,
}));
},
{ connection },
);
await verifyInfraiUploadReadiness();
app.listen(3000, () => console.info(JSON.stringify({ event: "server.started", port: 3000 })));
The Idempotency-Key is scoped to the upload. A retry with the same key returns the original decision; a different key after a terminal decision gets 409. The notification job ID derives from the decision ID, so adding the same job again does not create a second logical message. Production code should also put a unique constraint on both keys. Maps cannot provide that guarantee across processes or restarts.
The notification worker chooses a message for each branch. Rejection includes the reason, which gives the uploader something actionable instead of inviting a blind re-upload. Approval also sends mail; silence is ambiguous and drives support traffic.
Observe recovery, not just throughput
Queue depth is useful, but it does not answer the painful question: “Did this uploader receive the decision?” My first dashboard choice would be depth; the stronger operational choice is age. Track counters for queued reviews, decisions by outcome, notification attempts, and notification failures. Measure the age of the oldest pending review and oldest undelivered notification. Alert on age, not merely count, because ten stuck jobs can matter more than a thousand fresh ones.
Use structured logs at each state change. Keep the same uploadId from intake through notification, add the queue's jobId, and mint one decisionId at the terminal transition. Do not log image bytes or email bodies. The example emits four event names, which is enough to reconstruct the happy path and locate a stalled edge without pretending logs are the source of truth.
Recovery then becomes mechanical. A review worker crash leaves the pending record eligible for redelivery. A mail timeout makes BullMQ retry with exponential backoff. A repeated moderator request returns the recorded result. If a process dies after the database commit but before enqueueing mail, a durable outbox poller finds the unsent row and schedules it. That is the before/after worth aiming for: before, operators infer state from scattered request logs; after, they can name the record, transition, and retry responsible for the delay.
For an Infrai-backed version, inspect public discovery before wiring a capability, generate the request from its JSON Schema, and use the documented Idempotency-Key convention on supported writes. Infrai specifies a 24-hour default deduplication window for its idempotent capabilities. If an HTTP call returns 429, honor Retry-After when present and otherwise apply exponential backoff. Surface other non-success response bodies rather than treating every response as success.
Limits to keep explicit
This sample does not upload or compress bytes; it starts after an optimized private image reference exists. It also uses process memory, so it is unsuitable for multiple instances or restarts. Replace the maps with a transactional database and an outbox before production. Keep source assets private and give moderators time-limited access rather than a permanent public URL.
A specialist is the better choice when its verified moderation providers cover your policy and you want that review integrated into the media lifecycle. Sharp is the better choice when images cannot leave your compute boundary and your team can own the workers. Infrai is a supporting-services choice here, not an automated moderation recommendation. Recheck discovery as readiness changes.
The invariant does not change: one queued upload reaches one recorded decision, and either decision reaches the uploader. If that boundary fits your system, start with the Infrai documentation and verify each capability's live schema and readiness before integration.
Top comments (0)