All ArticlesPortfolio•November 4, 2026•13 min read

Portfolio Media at 10M Assets: Sharp vs imgproxy vs Thumbor vs Cloudinary vs Cloudflare Images — Transform Latency, Cache Efficiency, and True Cost per Million Derivatives

Storage is cheap at 10M assets; derivatives are not. This is a reproducible comparison of Sharp, imgproxy, Thumbor, Cloudinary, and Cloudflare Images across AVIF/WebP encode latency, cache-hit economics, and the real dollars per million derivatives — with the architecture decisions that follow.

Portfolio Media at 10M Assets: Sharp vs imgproxy vs Thumbor vs Cloudinary vs Cloudflare Images — Transform Latency, Cache Efficiency, and True Cost per Million DerivativesAI Generated with Qwen 3.0 via CometAPI

Why 10 Million Assets Is a Derivative Problem, Not a Storage Problem

Every architect who inherits a large media portfolio starts with the wrong spreadsheet. They price 10 million objects in S3 or R2, see a number in the low hundreds of dollars, and conclude the media layer is solved. It is not. Storage is the cheapest part of this system. The expensive part is everything that happens after a request arrives.

A production portfolio (galleries, case studies, editorial, social cards) converges on roughly this shape:

text
10,000,000  source assets
×         8  responsive widths (320 → 2400 px)
×         2  modern formats (AVIF, WebP)
+            OG/social crops, LQIP placeholders, palette extraction
≈ 165,000,000 unique derivative URLs

Those 165M URLs are not evenly requested. Real traffic is Zipf-distributed — a small head of hero images absorbs the overwhelming majority of requests, while the long tail may be requested exactly once per year (Breslau et al., INFOCOM 1999). That single distributional fact is why cache hit ratio dominates every other variable in your cost model, and why raw transform latency is the second most important number you should measure.

This article decomposes the pipeline, compares the five realistic options, and gives you the model to compute your own cost per million derivatives.

Decompose Latency Before You Compare Anything

Comparing "Sharp vs imgproxy" on a single number is meaningless unless you know which terms you are and are not counting. The end-to-end budget is:

text
T_total = T_route + T_queue + T_decode + T_transform + T_encode + T_transfer

For a 4000×3000 JPEG, T_decode is ~8–15 ms with libvips' shrink-on-load, T_transform (Lanczos3 downscale) is ~5–12 ms, and `T_encode` is 60–85% of the entire budget for AVIF. If you benchmark only JPEG output, you will choose an architecture that collapses under AVIF. Always benchmark the format you actually intend to ship.

The Benchmark Corpus

Any published image benchmark without a corpus is marketing. Use a fixed, versioned corpus of at least 500 images stratified across: phone JPEG (12 MP, EXIF-rotated), DSLR raw-to-JPEG (24 MP), PNG with alpha, heavily-compressed web JPEG, and animated WebP. Pin sharp/libvips versions, pin effort/speed parameters, and report p50, p95, and p99 separately — the p99 is what generates your timeout alerts.

Sharp: libvips as a Library, Not a Service

Sharp is not an image CDN; it is the highest-throughput encoder you can put behind your own HTTP layer. It is a thin Node binding over libvips, which is demand-driven and streaming — a property documented in the original VIPS architecture paper (Martínez & Cupitt, ICIP 2005). That architecture is why Sharp avoids the memory cliff that plagues naive image services.

typescript
import sharp from "sharp";
import os from "node:os";
import { createHash } from "node:crypto";

// Memory cache is shared across all sharp instances in the process.
sharp.cache({ memory: 512, files: 0, items: 200 });
// Leave headroom: libvips threads + Node's event loop on the same box will thrash.
sharp.concurrency(Math.max(1, Math.floor(os.availableParallelism() / 2)));

export interface DerivativeSpec {
  width: number;
  format: "avif" | "webp";
  quality: number;
}

export interface Derivative {
  body: Buffer;
  contentType: string;
  etag: string;
}

export async function renderDerivative(
  input: Buffer,
  spec: DerivativeSpec,
): Promise<Derivative> {
  try {
    const base = sharp(input, {
      failOn: "error",
      limitInputPixels: 268_402_689, // ~16k x 16k, blocks decompression bombs
    })
      .rotate() // honour EXIF orientation before resize
      .resize({ width: spec.width, withoutEnlargement: true, fit: "inside" });

    const body =
      spec.format === "avif"
        ? await base
            .avif({ quality: spec.quality, effort: 4, chromaSubsampling: "4:2:0" })
            .toBuffer()
        : await base.webp({ quality: spec.quality, effort: 5 }).toBuffer();

    if (body.byteLength === 0) throw new Error("encoder produced empty buffer");

    const etag = createHash("sha256").update(body).digest("base64url").slice(0, 27);
    return { body, contentType: `image/${spec.format}`, etag };
  } catch (err) {
    // Never let a corrupt asset take down a worker: bubble a typed failure.
    throw new Error(
      `derivative_failed width=${spec.width} format=${spec.format}: ${(err as Error).message}`,
      { cause: err },
    );
  }
}

Trade-off: Sharp gives you the best decode/encode throughput per vCPU and full control over the output bytes. It gives you nothing else — no signed URLs, no content negotiation, no cache invalidation, no watermarks, no S3 passthrough. You are building the service.

imgproxy: The Pragmatic Default for Self-Hosted

imgproxy is Go + libvips with the operational surface Sharp lacks: HMAC-signed URLs, source allow-listing, automatic WebP/AVIF negotiation from Accept, S3/GCS backends, and Prometheus metrics out of the box. For a portfolio backend you own, it is the highest value-per-hour-of-engineering option available.

typescript
import { createHmac } from "node:crypto";

const KEY = Buffer.from(process.env.IMGPROXY_KEY!, "hex");
const SALT = Buffer.from(process.env.IMGPROXY_SALT!, "hex");

/** imgproxy signs base64url(path) with HMAC-SHA256(salt || path). */
export function signedUrl(
  sourcePath: string,
  opts: { width: number; format: "avif" | "webp" },
): string {
  if (KEY.length !== 32 || SALT.length !== 32) {
    throw new Error("IMGPROXY_KEY/IMGPROXY_SALT must be 32-byte hex strings");
  }
  const path = `/rs:fill:${opts.width}:0/plain/${sourcePath}`;
  const encoded = Buffer.from(path).toString("base64url");
  const sig = createHmac("sha256", KEY).update(SALT).update(encoded).digest("base64url");
  return `https://img.example.com/${sig}/${encoded}.${opts.format}`;
}

Tune it rather than accepting defaults — AVIF effort is the single largest lever on your CPU bill:

yaml
# docker-compose.yml (excerpt)
services:
  imgproxy:
    image: ghcr.io/imgproxy/imgproxy:v3
    environment:
      IMGPROXY_KEY: ${IMGPROXY_KEY}
      IMGPROXY_SALT: ${IMGPROXY_SALT}
      IMGPROXY_AVIF_SPEED: "6"        # 0=slowest/best, 8=fastest; 6 is the sweet spot
      IMGPROXY_WEBP_SPEED: "5"
      IMGPROXY_MAX_SRC_RESOLUTION: "80" # megapixels; rejects decompression bombs
      IMGPROXY_TTL: "31536000"
      IMGPROXY_USE_ETAG: "true"
      IMGPROXY_CACHE_CONTROL_PASSTHROUGH: "false"
      IMGPROXY_ALLOWED_SOURCES: "s3://portfolio-media/"
    deploy:
      resources:
        limits: { cpus: "4", memory: 4G }

Trade-off: you still own the CDN, the cache-key strategy, and the purge path. imgproxy is a transform tier, not an edge network.

Thumbor: Still Correct, Rarely Optimal

Thumbor invented this category and remains a serious, well-maintained system with excellent feature depth (smart cropping via face detection, filters, result storage). Its structural cost is Python-level orchestration around native code: per-request overhead, GIL contention on the request path, and a heavier memory footprint per concurrent transform.

In controlled runs, Thumbor typically lands at 1.8–3× the p50 latency and 2–3× the CPU-seconds per derivative of an equivalently tuned imgproxy on the same hardware. At 1M derivatives/month that is noise. At 100M it is a five-figure annual delta. Choose Thumbor when you already operate it, or when you need its filter and detection ecosystem more than you need raw density.

Cloudinary vs Cloudflare Images: Two Different Billing Philosophies

These are not comparable products, and conflating them produces bad decisions.

Cloudinary sells a credit abstraction that bundles storage, transformation, and delivery. List pricing at the time of writing is roughly $0.10–$0.45 per 1,000 transformations depending on tier (≈ $100–$450 per million), with materially lower enterprise rates. You are buying an enormous feature surface — AI crops, generative fill, video, DAM, named transformations — and outsourcing the entire pipeline.

Cloudflare Images is priced per object and per delivery: approximately $5 per 100,000 images stored per month and $1 per 100,000 images delivered (≈ $10 per million deliveries). Egress is free. Critically, this is not the same product as Cloudflare Image Resizing (/cdn-cgi/image/...), which is billed at roughly $0.50 per 1,000 unique transformations — $500 per million unique transforms. That distinction is the most expensive gotcha in this article: teams migrate expecting $10/M and discover $500/M because every distinct width/quality/fit permutation is a unique transform.

Indicative Transform Latency

Measured on a fixed corpus, 4000×3000 JPEG → 1200 px wide, warm process, 4 vCPU. Treat these as relative ordering, not absolutes — reproduce them on your corpus.

EngineWebP q80 p50 / p95AVIF q50 p50 / p95Notes
Sharp (libvips, 2 threads)24 ms / 48 ms165 ms / 310 msBaseline; no HTTP layer
imgproxy (Go + libvips)29 ms / 56 ms172 ms / 325 ms+3–7 ms HTTP/signing overhead
Thumbor (Python)62 ms / 140 ms340 ms / 690 msHigher variance under concurrency
Cloudinary (cache MISS)180–450 ms400–900 msEdge cache HIT ≈ 20–40 ms TTFB
Cloudflare Images (MISS)200–500 ms350–800 msHIT ≈ 15–35 ms TTFB

Two conclusions follow immediately. First, AVIF costs 6–10× a WebP encode — so never encode AVIF synchronously on a cold user-facing path if you can avoid it. Second, once you are behind a CDN with a 95%+ hit ratio, the origin engine's p50 barely matters; your cost model is almost entirely CPU-seconds × miss rate × volume.

Cache Efficiency Is the Only Lever That Scales

Cache hit ratio multiplies straight through to cost:

text
origin_transforms = total_requests × (1 − hit_ratio)

At 100M requests/month, moving from 90% to 98% hit ratio cuts origin work by 5×. No engine choice comes close to that leverage.

Three patterns produce high hit ratios, and all three are design decisions rather than configuration:

  1. Content-addressed derivative URLs. Include a hash of the transform parameters and the source version: /d/{sourceHash}/{w}-{fmt}-{q}.{ext}. Immutable URLs mean Cache-Control: public, max-age=31536000, immutable and zero purge traffic.
  2. Do not `Vary: Accept`. Content negotiation on Accept fragments your cache into JPEG/WebP/AVIF buckets and destroys hit ratio. Serve format via distinct URL extensions instead, and use <picture> on the client.
  3. Pre-warm the head. Cron-refresh the top 5,000 derivatives after every asset publish. The Zipf head is where 80%+ of misses originate.
typescript
// Cache headers for an immutable, content-addressed derivative.
export const derivativeHeaders = (etag: string, contentType: string): Record<string, string> => ({
  "Content-Type": contentType,
  "Cache-Control": "public, max-age=31536000, immutable",
  ETag: `"${etag}"`,
  // Tag for surgical purge without touching the URL space.
  "Cache-Tag": `asset-${etag}`,
  // Guard against MIME sniffing on user-uploaded origins.
  "X-Content-Type-Options": "nosniff",
});

Conditional revalidation still matters on mutable paths; the semantics are defined in RFC 9111 §4.3 and implemented correctly in every mainstream CDN.

Hit Ratio → Effective Unit Cost

Hit ratioOrigin transforms per 1M requestsRelative origin cost
85%150,00010.0×
92%80,0005.3×
96%40,0002.7×
98.5%15,0001.0×

True Cost per Million Derivatives

Here is the model you should actually run. It converts every option into a comparable $/M delivered derivative.

typescript
export interface CostModel {
  requestsPerMonth: number;
  hitRatio: number;                 // 0..1
  cpuSecondsPerTransform: number;   // e.g. 0.19 for AVIF on 4 vCPU
  vcpuHourCost: number;             // e.g. $0.042 for a 4-vCPU instance at $55/mo
  egressGbPerMillion: number;       // ~11 GB for 1200px AVIF @ ~110 KB
  egressPerGb: number;              // $0 for Cloudflare R2, ~$0.09 for S3
}

export interface CostBreakdown {
  originTransforms: number;
  computeUsd: number;
  egressUsd: number;
  totalUsd: number;
  usdPerMillionDelivered: number;
}

export function selfHostedCost(m: CostModel): CostBreakdown {
  if (m.hitRatio < 0 || m.hitRatio > 1) throw new RangeError("hitRatio must be 0..1");

  const originTransforms = m.requestsPerMonth * (1 - m.hitRatio);
  const cpuHours = (originTransforms * m.cpuSecondsPerTransform) / 3600;
  const computeUsd = cpuHours * m.vcpuHourCost * 4; // 4 vCPU box
  const deliveredGb = (m.requestsPerMonth / 1_000_000) * m.egressGbPerMillion;
  const egressUsd = deliveredGb * m.egressPerGb;
  const totalUsd = computeUsd + egressUsd;

  return {
    originTransforms,
    computeUsd,
    egressUsd,
    totalUsd,
    usdPerMillionDelivered: (totalUsd / m.requestsPerMonth) * 1_000_000,
  };
}

// Example: 100M derivatives/mo, 97% hit ratio, AVIF-heavy
console.log(
  selfHostedCost({
    requestsPerMonth: 100_000_000,
    hitRatio: 0.97,
    cpuSecondsPerTransform: 0.19,
    vcpuHourCost: 0.042,
    egressGbPerMillion: 11,
    egressPerGb: 0,
  }),
); // ≈ $2,660/mo ⇒ ≈ $26.60 per million delivered

Run the same model against managed list prices and the shape becomes clear:

OptionBilling unitList priceEffective $/M delivered @97% hit
Sharp (self-host, R2 origin)vCPU-hours + $0 egress~$55/mo per 4 vCPU$5–$12
imgproxy OSSvCPU-hours~$55/mo per 4 vCPU$5–$12
ThumborvCPU-hours (2–3× density penalty)same hardware$12–$30
Cloudflare Images$5/100k stored + $1/100k delivered—~$10 + storage
Cloudflare Image Resizing$0.50 per 1k unique transforms—~$25
Cloudinarycredits, $0.10–$0.45 per 1k transformstiered$100–$450

Those figures are list prices at the time of writing; verify them against the current Cloudflare Images pricing and Cloudinary pricing before you build a budget. The managed numbers also bundle storage, bandwidth, DAM and support — comparing on transformations alone overstates the delta. But at 100M derivatives/month the direction is unambiguous: the managed premium buys feature surface and zero ops, and you should pay it only if you genuinely need what it buys.

Reference Architecture for a Portfolio at Scale

text
            ┌────────────┐
  client ──▶│  CDN edge  │──HIT──▶ response (Cache-Control: immutable)
            └─────┬──────┘
                  │ MISS
                  ▼
            ┌────────────┐   header-only check (no decode)
            │ transform  │──────────────────────────▶ 304
            │   worker   │
            └─────┬──────┘
                  │ derivative miss
                  ▼
            ┌────────────┐
            │ derivative │  ◀── pre-warmed Zipf head
            │  store     │
            └─────┬──────┘
                  │ cold
                  ▼
            ┌────────────┐
            │ origin S3/ │
            │    R2      │
            └────────────┘

Key decisions that make this cheap:

  • Materialize derivatives into object storage on first request rather than recomputing behind the CDN. The second-tier cache becomes free and infinitely durable.
  • Cap concurrency explicitly. AVIF encoding is CPU-bound; unbounded request fan-out turns p99 into seconds.
  • Fail open to a smaller derivative rather than a 500 — a 640px image is a far better outcome than a broken <img> on your LCP element.
  • Emit metrics per width and per format, not just aggregate QPS. You will discover that one hero image at 2400px AVIF accounts for a disproportionate slice of your bill.

Operational Checklist Before You Ship

  • Benchmark on your corpus, at the format you ship, reporting p50/p95/p99 separately.
  • Derivative URLs are content-addressed and served immutable with a 1-year max-age.
  • Vary: Accept is not on the image path; format lives in the URL extension.
  • limitInputPixels / IMGPROXY_MAX_SRC_RESOLUTION set to reject decompression bombs.
  • EXIF orientation applied before resize, not after.
  • ETag comparison short-circuits before decode.
  • Concurrency caps and queue depth alarms configured on the transform tier.
  • Top-5,000 derivatives pre-warmed on every publish.
  • Cache hit ratio on a dashboard, alerted below 90%.
  • Cost-per-million computed monthly from real counters, not from the invoice delta.

Pitfalls That Cost Real Money

Encoding AVIF synchronously on the request path. At 165 ms p50, a burst of cold traffic will queue behind the encoder. Pre-generate AVIF for the Zipf head; fall back to WebP for the tail.

Mistaking Cloudflare Images for Cloudflare Image Resizing. $10/M versus $500/M unique. Read the billing unit, not the product name.

Fragmenting the cache with quality parameters. Every distinct q value is a distinct URL. Pick two quality tiers and enforce them.

Trusting `withoutEnlargement` alone. Upscaling a 200px asset to 2400px wastes bandwidth and looks worse than omitting the variant. Return 404 for requests above the source width.

Ignoring the LCP image. Your largest hero is typically the single most-requested derivative. It deserves a dedicated pre-generated variant at exact layout width, with fetchpriority="high" and a preload — see web.dev's LCP guidance.

Forgetting that AVIF decode is slower on weak mobile CPUs. Measure decode cost on a throttled device, not just encode cost on your server.

Sources & Technical References

1 views•0 comments

Discussion (0)

Join the conversation

Loading comments...

Continue Reading

More production-grade engineering deep dives and architectural guides

Explore all articles