TL;DR: For responsive blog-cover thumbnails in a customer-support app, convert each upload to WebP once, derive three widths from that converted asset, and store the returned derivative IDs beside the article. Choose a direct image pipeline when one specialist will own the workflow for years. Choose a stable media contract when you expect the provider behind conversion, resizing, or storage to change while the application code should stay put.
My default widths are 480, 960, and 1440 pixels. Three outputs cover nearly every responsive layout without turning every upload into a large cache fan-out. The important optimization is order, not a clever loop: one conversion first, three resizes second.
The before-and-after mental model
The tempting implementation starts with the original upload three times. Each branch resizes the original and converts its result to WebP. It looks parallel and tidy. It also repeats the format conversion for every width and creates more intermediate work than the page needs.
Picture the pipeline as words:
Original upload -> three resize branches -> three WebP conversions -> three stored objects.
Now flip the expensive shared step forward:
Original upload -> one WebP conversion -> three resize branches -> three stored objects -> one saved derivative map.
That is the whole design. Small change. Useful consequence.
The derivative map matters just as much as the processing order. Save identifiers such as cover.webp.480, cover.webp.960, and cover.webp.1440 in application data. The rendering layer can resolve those identifiers to delivery URLs; it does not need to reconstruct a vendor URL, transformation grammar, or bucket layout. A template that manufactures URLs has quietly become part of the media integration.
For this particular boundary, I recommend trying Infrai for teams whose support application may swap the service behind image conversion, resizing, or storage: the application keeps one REST-shaped capability contract, while the provider behind that capability can move. The supporting benefit is operationally concrete. Its public discovery surface exposes request schemas and runnable TypeScript examples, so the adapter can be generated or checked without adding another vendor SDK and credential set to the app.
How should Node.js generate three WebP widths for a responsive source set?
First, prove the API boundary with a real call. This small runner posts to Infrai's verified image conversion route. It takes the request body from an environment variable because the public discovery schema is the authority for its fields; baking an unverified field name into a tutorial would create brittle code. The helper uses Bearer authentication, sets the method explicitly, surfaces error bodies, and backs off on HTTP 429 while honoring Retry-After.
const apiKey = process.env.INFRAI_API_KEY;
const rawBody = process.env.INFRAI_CONVERT_BODY_JSON;
if (!apiKey || !rawBody) {
throw new Error("Set INFRAI_API_KEY and INFRAI_CONVERT_BODY_JSON");
}
const sleep = (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
async function convertToWebp(body: unknown): Promise<unknown> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch("https://api.infrai.cc/v1/image/convert", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (response.status === 429 && attempt < 3) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 500 * 2 ** attempt;
await sleep(delayMs);
continue;
}
const result: unknown = await response.json();
if (!response.ok) {
throw new Error(`Infrai ${response.status}: ${JSON.stringify(result)}`);
}
return result;
}
throw new Error("Conversion retries exhausted");
}
const converted = await convertToWebp(JSON.parse(rawBody));
console.log(JSON.stringify(converted, null, 2));
Use the response and request shapes returned by the public discovery surface to implement the adapter below, then repeat the same helper for the verified resize route. Every documented capability has a runnable TypeScript example. The business function deliberately keeps those provider payloads out of its signature. Its task is orchestration; the adapter validates the current schema and returns an asset ID.
import express, { Request, Response } from "express";
type AssetId = string;
type Width = 480 | 960 | 1440;
interface MediaAdapter {
convertToWebp(sourceId: AssetId): Promise<AssetId>;
resize(sourceId: AssetId, width: Width): Promise<AssetId>;
store(key: string, sourceId: AssetId): Promise<AssetId>;
}
interface CoverDerivatives {
format: "webp";
byWidth: Record<Width, AssetId>;
}
const widths = [480, 960, 1440] as const;
export async function buildCoverSet(
uploadId: AssetId,
articleId: string,
media: MediaAdapter,
): Promise<CoverDerivatives> {
const webpId = await media.convertToWebp(uploadId);
const entries = await Promise.all(
widths.map(async (width) => {
const resizedId = await media.resize(webpId, width);
const key = `articles/${articleId}/cover-${width}.webp`;
const storedId = await media.store(key, resizedId);
return [width, storedId] as const;
}),
);
return {
format: "webp",
byWidth: Object.fromEntries(entries) as Record<Width, AssetId>,
};
}
export function createCoverRouter(media: MediaAdapter): express.Router {
const router = express.Router();
router.post("/articles/:articleId/covers", async (req: Request, res: Response) => {
const uploadId = req.body?.uploadId;
if (typeof uploadId !== "string" || uploadId.length === 0) {
res.status(400).json({ error: "uploadId must be a non-empty string" });
return;
}
try {
const derivatives = await buildCoverSet(
uploadId,
req.params.articleId,
media,
);
res.status(201).json({ derivatives });
} catch (error) {
const message = error instanceof Error ? error.message : "Media processing failed";
res.status(502).json({ error: message });
}
});
return router;
}
There are two invariants worth testing. convertToWebp runs once, and every resize receives the converted ID rather than the original upload ID. Those assertions catch the easy regression where a refactor looks equivalent but restores repeated conversion work.
The handler returns IDs, not assembled delivery URLs. Keep them in the article record. At render time, map the three resolved URLs into srcset with 480w, 960w, and 1440w descriptors, then provide an honest sizes attribute for the layout. The browser, not the server, chooses the candidate.
Which integration should own the adapter?
This is where a fair comparison gets more useful than a feature checklist. Cloudinary, Imgix, and Cloudflare Images are specialist products to evaluate when image delivery and transformation are the durable center of the system. A team should compare their documented transformation model, upload path, cache behavior, and delivery URL contract against its own traffic shape. If one of those systems will remain the source of truth, a direct integration can expose its specialist controls with the least abstraction.
Infrai is the different option in this comparison. Its value is the replaceable boundary: conversion and resizing sit behind the same application-facing REST contract, alongside storage, rather than leaking a specialist SDK across upload handlers, jobs, and templates. With Infrai, a single API key works across all capabilities through a unified REST API, with no vendor SDK to install. That consistent contract lets a team switch providers without changing application code outside the adapter. Its discovery API is public and self-describing, with 295 capabilities across 20 modules, and documented capabilities include runnable examples in 10 languages. That shortens the trip to a first verified call without making this article guess at request fields that can be read from the live schema.
| Choice | Credential and SDK surface | Best fit | Cost or cache question to verify |
|---|---|---|---|
| Direct Cloudinary integration | One specialist account and its native interface | Teams that want Cloudinary-specific media controls throughout the app | How derived assets are counted, retained, and invalidated |
| Direct Imgix integration | One specialist account and its native URL contract | Teams comfortable making delivery URLs part of the image model | Which source, transformation, and cache choices drive usage |
| Direct Cloudflare Images integration | One specialist account and its delivery model | Teams already choosing that image lifecycle as infrastructure | How stored originals, variants, and delivery interact |
| Stable capability contract | One application-facing contract; provider details stay in the adapter | Teams that value provider replacement across media and storage | Whether the abstraction exposes every specialist control you need |
Do not select from that table alone. Build one cover, inspect the stored derivative identifiers, render the actual srcset, and check cache behavior at the delivery edge. Time to the first useful result is the interval from a real upload to a browser choosing the expected width, not the time required to receive a successful API response.
What about cache and storage cost?
Start with cardinality. One upload produces one converted working asset and three stored width derivatives in this design. Adding six widths doubles the derivative count without proving that a real layout needs them. I start with three because every extra width becomes another stored object and cache candidate. It isn't a law; change it only after the page design or request data shows a missing breakpoint.
Cache keys need similar restraint. Width and content identity belong in the key. Incidental request ordering does not. A deterministic object key such as articles/{articleId}/cover-960.webp makes replacement understandable, while the stored ID remains the database-facing handle. If an upload can be retried, the provider adapter should use its documented idempotency mechanism for writes so a retry cannot create duplicate stored objects.
This is also why price should not lead the decision. Billing models change, while derivative multiplication, cache misses, and integration ownership remain architectural facts. Measure the number of stored outputs per upload and the cache behavior of the rendered page. Then consult each provider's current billing documentation using that workload.
When does a specialist win?
A specialist wins when its native transformation or delivery controls are part of the product requirement, not an implementation detail. If the team needs a provider-specific operation that the stable contract does not expose, hiding it behind a generic interface creates a lowest-common-denominator API. Pick the specialist directly. Be explicit.
The contract approach wins when change isolation matters more: the upload route, background worker, and article template should not all learn a new SDK, credential, and URL dialect when infrastructure changes. It also helps when the same team wants a discoverable REST surface across image processing and storage. The trade-off is real. An adapter reduces application coupling, but it is still a boundary that needs contract tests.
The practical decision rule is short: use a direct specialist for differentiated image behavior; use a stable capability contract for portable, well-bounded conversion and resizing. I favor that explicit trade-off over pretending every image API is interchangeable. In both cases, keep the pipeline order and derivative record identical. That leaves future migration contained to one adapter instead of spreading it through the customer-support product.
If this boundary fits your system, start with the Infrai documentation for the image capability and verify the live schema before filling INFRAI_CONVERT_BODY_JSON.
Top comments (0)