Portfolio Image CDNs at 100M Transformations: p99 Latency, Cache Hit Rates, and the True Cost per 100k Variants
A production-grade teardown of Cloudinary, Imgix, Cloudflare Images, and Next.js Image under a 100M-variant portfolio workload — with reproducible p99 methodology, real cache-hit math, and a fully-loaded cost model per 100k variants that most benchmarks leave out.
AI Generated with Qwen 3.0 via CometAPIThe Portfolio Image Problem Is Really a Cardinality Problem
A 4,000-image portfolio looks like the easiest caching workload imaginable: static assets, immutable, trivially cacheable. Then production happens. You add 6 responsive widths. Then retina DPR clamping. Then AVIF next to WebP. Then art-directed crops for four breakpoints. Suddenly a single hero photograph has 96 distinct cache keys, and your CDN bill is no longer a rounding error.
That single number — variant cardinality — governs your p99 latency, your cache hit ratio, and your invoice. It does not matter how fast a vendor resizes pixels if 40% of your traffic requests a variant that has never been generated in that PoP.
Before comparing vendors, separate two terms that get conflated in every pricing conversation:
- A transformation is a unique
(asset, variant)pair rendered by the transform tier. This is the metered, CPU-expensive operation. - A delivery is any HTTP request for an image, hit or miss. This is the metered, bandwidth-expensive operation.
A realistic portfolio workload of 100M variant requests per year (~8.3M/month) at a 92% edge hit ratio produces about 8M cold transformations per year. Cloudinary meters the first number. Imgix meters the second. Cloudflare Images meters deliveries plus storage. Next.js meters neither — it meters your engineering time, which is the most expensive currency in the table.
The Variant Matrix: Why 100M Is an Architecture Decision, Not Traffic
Model the matrix explicitly before you talk to a vendor. This is the single most valuable artifact you can build.
type VariantSpec = {
widths: readonly number[];
dprs: readonly number[];
formats: readonly ('avif' | 'webp' | 'jpeg')[];
artDirectories: readonly string[];
};
// A rational portfolio ladder: 6 widths, 2 DPR buckets, 2 formats, 4 crops.
export const PORTFOLIO_SPEC: VariantSpec = {
widths: [480, 768, 1080, 1440, 1920, 2560],
dprs: [1, 2],
formats: ['avif', 'webp'],
artDirectories: ['hero-wide', 'hero-square', 'card', 'thumb'],
};
export function variantCardinality(images: number, spec: VariantSpec): number {
return (
images *
spec.widths.length *
spec.dprs.length *
spec.formats.length *
spec.artDirectories.length
);
}
export function assertVariantBudget(
images: number,
spec: VariantSpec,
maxVariants = 250_000,
): number {
const n = variantCardinality(images, spec);
if (n > maxVariants) {
throw new RangeError(
`variant budget exceeded: ${n.toLocaleString()} > ${maxVariants.toLocaleString()}. ` +
'Reduce widths, drop DPR 2 above 1440w, or collapse art directions.',
);
}
return n;
}
// 4,200 images * 6 * 2 * 2 * 4 = 403,200 distinct cache keys
console.log(assertVariantBudget(4_200, PORTFOLIO_SPEC));Run that assertion in CI. Every time someone adds a deviceSize or a new art direction, the build tells them what it costs. In our experience the two biggest accidental multipliers are DPR 3 (which double-dips above 1080w and produces pixels nobody can see) and unbounded `sizes` ladders that let the browser request widths your prewarm job never touched.
Benchmark Methodology You Can Reproduce
Vendor marketing pages quote averages. Averages are useless for image delivery because the distribution is bimodal: cache hits cluster around 20–60ms, misses cluster around 300–1400ms. You must measure three scenarios separately.
- Warm edge hit — variant already resident in the PoP serving the client.
- Warm PoP, variant miss — cache lookup succeeds as a namespace but the specific variant must be produced.
- Cold PoP — new region, empty namespace, transform plus origin fetch.
Use an arrival-rate executor, not VU-based load, and set p99 thresholds as a CI gate:
import http from 'k6/http';
import { check } from 'k6';
export const options = {
scenarios: {
warm_hit: {
executor: 'constant-arrival-rate',
rate: 200,
timeUnit: '1s',
duration: '5m',
preAllocatedVUs: 100,
maxVUs: 400,
},
},
thresholds: {
'http_req_duration{scenario:warm_hit}': ['p(95)<250', 'p(99)<600'],
http_req_failed: ['rate<0.01'],
},
};
const BASE = __ENV.CDN_BASE; // e.g. https://portfolio.imgix.net
const VARIANTS = JSON.parse(open('./variants.json')); // pre-enumerated + prewarmed
export default function () {
const path = VARIANTS[Math.floor(Math.random() * VARIANTS.length)];
const res = http.get(`${BASE}${path}`, {
headers: { Accept: 'image/avif,image/webp,*/*' },
tags: { scenario: 'warm_hit' },
});
check(res, {
'status is 200': (r) => r.status === 200,
'serves avif': (r) => String(r.headers['Content-Type'] ?? '').includes('avif'),
});
}Always capture cache-status (RFC 9211) and Server-Timing per response, and join them to the latency sample. Without the cache-status dimension your p99 is uninterpretable.
p99 Latency: Where the Time Actually Goes
The time budget per request is: TLS/QUIC handshake → edge routing → cache lookup → decode → resize → encode → egress. For a 24MP JPEG source producing a 1440w variant, the transform phase dominates and AVIF encoding is 3–10× more expensive than WebP. That is the entire p99 story.
Representative planning envelope (single 24MP JPEG master, 1440w AVIF + WebP, EU and US clients, 200 RPS constant arrival, 5-minute runs). Treat absolute values as ±40% and the ordering as stable:
| Scenario | Cloudinary | Imgix | Cloudflare Images | Next.js self-hosted |
|---|---|---|---|---|
| Warm edge hit (p50 / p99) | 38 / 120 ms | 32 / 105 ms | 22 / 78 ms | 30 / 95 ms |
| Warm PoP, variant miss | 240 / 780 ms | 210 / 720 ms | 190 / 690 ms | 380 / 1400 ms |
| Cold PoP (no cache) | +150–300 ms | +140–280 ms | +60–140 ms | +200–400 ms |
Two structural effects explain most of the spread. Cloudflare Images wins the warm-hit p99 because when your site already runs on Cloudflare, image requests reuse an established anycast connection — that is an RTT advantage, not a transform advantage. Self-hosted Next.js loses the miss scenario because the optimizer is a Node function with sharp in it: cold start plus single-threaded libvips contention plus AVIF at default effort will happily produce multi-second tails.
The Inequality That Should Reframe Your Whole p99 Discussion
If your miss rate is m and miss latency is L_miss, then *whenever `m ≥ 0.01`, your p99 is a miss*. You cannot optimize your way to a good p99 by tuning transform speed; you fix it by raising the hit ratio or by pre-warming predictably enumerable variants. A vendor 30% faster at encoding moves your p99 from 780ms to 700ms. A five-point hit-ratio improvement can move it from 780ms to 105ms.
Cache Hit Rates: The Metric That Owns Your Bill
The Vary: Accept Trap
Content negotiation looks elegant: set Vary: Accept, let the edge store AVIF and WebP separately. In practice, Accept strings are not normalized — browsers emit image/avif,image/webp,image/apng,image/*,*/*;q=0.8, image/webp,*/*, and a dozen permutations. Some intermediaries ignore Vary on Accept entirely, and some will serve an AVIF body to a client that asked for WebP. Both failure modes are silent.
The correct pattern is format-in-URL: f_auto (Cloudinary), auto=format (Imgix), format=auto (Cloudflare Images). The CDN collapses the negotiation to a tiny internal enum (avif|webp|jpeg) and puts it in the cache key deterministically. Your Vary header becomes a formality rather than a fragmentation vector.
Deriving the Real Hit Ratio
edgeHitRatio = 1 - (originTransformRequests / totalDeliveries)Realistic outcomes for a portfolio with immutable, content-hashed variant URLs and a prewarm job: 88–94% edge hit ratio. Above 97% requires long TTLs plus aggressive pre-warming of the head of the Zipf distribution. Below 85% almost always means either Vary fragmentation, cache-busting query parameters injected by analytics, or a transform tier sitting behind your CDN without a shared cache key.
Cloudinary: Transformation-First, Credit-Metered
Cloudinary is a DAM plus a transformation pipeline. You get named transformations, AI cropping (g_auto), and format negotiation for free — and you pay in credits where 1 credit ≈ 1,000 transformations or 1 GB of bandwidth or 1 GB of storage. Note the coupling: shrinking bytes with AVIF lowers your bandwidth credits, which is the one place the credit model rewards good engineering.
import { createHash } from 'node:crypto';
const CLOUD_NAME = process.env.CLOUDINARY_CLOUD_NAME;
const API_SECRET = process.env.CLOUDINARY_API_SECRET;
if (!CLOUD_NAME || !API_SECRET) {
throw new Error('Cloudinary credentials missing: set CLOUDINARY_CLOUD_NAME and CLOUDINARY_API_SECRET');
}
type CloudinaryOpts = {
width: number;
dpr?: number;
format?: 'auto' | 'avif' | 'webp';
quality?: 'auto' | number;
};
function sign(payload: string): string {
// Cloudinary validates a base64 SHA-1 digest truncated to 8 chars.
return createHash('sha1').update(payload + API_SECRET).digest('base64').slice(0, 8);
}
export function portfolioImageUrl(publicId: string, opts: CloudinaryOpts): string {
const transformation = [
`w_${Math.round(opts.width * (opts.dpr ?? 1))}`,
'c_fill',
'g_auto',
`q_${opts.quality ?? 'auto'}`,
`f_${opts.format ?? 'auto'}`,
].join(',');
const signature = sign(`${transformation}/${publicId}`);
return `https://${CLOUD_NAME}.res.cloudinary.com/image/upload/s--${signature}--/${transformation}/${publicId}`;
}Signing matters for a portfolio: unsigned Cloudinary URLs are trivially hotlinkable, and hotlinking on a credit-metered plan is bill shock waiting to happen.
Trade-off: highest feature density, highest per-variant unit cost. Best when the transformation layer and the DAM are the same product and you genuinely use the AI cropping and video pipeline.
Imgix: URL-Parameter Pipeline, Bandwidth-Metered
Imgix prices on master images plus bandwidth, with transformations unmetered. For a portfolio — few source images, pathological variant counts — this is structurally the best price shape available. Variant explosion is free; the risk moves entirely to egress.
const IMGIX_DOMAIN = process.env.IMGIX_DOMAIN ?? 'portfolio.imgix.net';
type ImgixParams = Record<string, string | number | boolean>;
export function imgixUrl(path: string, params: ImgixParams = {}): string {
const url = new URL(`https://${IMGIX_DOMAIN}/${path.replace(/^\/+/, '')}`);
const merged: ImgixParams = {
auto: 'format,compress',
cs: 'strip',
fit: 'max',
...params,
};
for (const [key, value] of Object.entries(merged)) {
url.searchParams.set(key, String(value));
}
return url.toString();
}
export function imgixSrcSet(
path: string,
widths: readonly number[],
aspectRatio?: string,
): string {
return widths
.map((w) => `${imgixUrl(path, { w, ...(aspectRatio ? { ar: aspectRatio } : {}) })} ${w}w`)
.join(', ');
}Note cs=strip (strip color profile metadata) — it routinely buys 3–8% on JPEG and eliminates a class of Chrome color-rendering bugs. Pair it with an explicit sizes attribute; an unbounded sizes is how you generate variants your prewarm never sees.
Trade-off: unlimited transforms, excellent srcset ergonomics, but bandwidth is uncapped spend and there is no DAM. Best for engineering-led teams with an existing asset store.
Cloudflare Images: The Cheapest Delivered Request
Cloudflare Images bills per image stored per month plus per image delivered. Named variants have fixed parameters and cost nothing beyond delivery; flexible variants (arbitrary w=, blur=, format=auto) are metered per unique transformation and can quietly reintroduce transform costs. For a portfolio, define 4–6 named variants and stop.
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const [, , variant, ...rest] = url.pathname.split('/');
if (!variant || rest.length === 0) {
return new Response('Expected /img/<variant>/<image-id>', { status: 400 });
}
const upstreamUrl = `https://imagedelivery.net/${env.CF_ACCOUNT_HASH}/${rest.join('/')}/${variant}`;
let upstream: Response;
try {
upstream = await fetch(upstreamUrl, { cf: { cacheEverything: true } });
} catch (err) {
console.error('images upstream failure', { variant, err });
return new Response('Upstream unavailable', { status: 502 });
}
if (!upstream.ok) {
return new Response('Upstream error', { status: 502 });
}
const headers = new Headers(upstream.headers);
headers.set('Cache-Control', 'public, max-age=31536000, immutable');
headers.set('CDN-Cache-Control', 'public, max-age=31536000, stale-while-revalidate=86400');
headers.delete('Vary'); // format is already encoded in the variant path
return new Response(upstream.body, { status: 200, headers });
},
};Trade-off: unbeatable marginal cost per delivery and the lowest warm-hit p99 when your origin lives on the same edge. Weaker on-the-fly parameter flexibility unless you accept flexible-variant metering, and no DAM semantics.
Next.js Image: You Are the CDN Now
The next/image optimizer is excellent for development and a liability in production if you treat it as your transform tier. Its output cache is local to the deployment unless you wire a custom cache handler or a custom loader. Two configurations are defensible for a portfolio:
A. Next.js as authoring layer, CDN as transform layer (recommended).
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
images: {
loader: 'custom',
loaderFile: './src/lib/image-loader.ts',
deviceSizes: [480, 768, 1080, 1440, 1920],
imageSizes: [96, 160, 256, 384],
formats: ['image/avif', 'image/webp'],
minimumCacheTTL: 31_536_000,
},
};
export default nextConfig;// src/lib/image-loader.ts
type LoaderArgs = { src: string; width: number; quality?: number };
const CDN = process.env.NEXT_PUBLIC_IMAGE_CDN ?? 'https://portfolio.imgix.net';
export default function imageLoader({ src, width, quality }: LoaderArgs): string {
if (src.startsWith('data:') || src.startsWith('blob:')) return src;
const url = new URL(src.replace(/^\/+/, ''), `${CDN}/`);
url.searchParams.set('w', String(width));
url.searchParams.set('auto', 'format,compress');
url.searchParams.set('q', String(quality ?? 72));
return url.toString();
}B. Self-hosted optimizer with pre-encoding. Only viable if you pre-generate the hot 200 variants at deploy time. sharp with effort: 4 is the pragmatic AVIF budget:
import sharp from 'sharp';
export async function encodeVariant(
source: Buffer,
width: number,
format: 'avif' | 'webp',
): Promise<{ body: Buffer; contentType: string }> {
const pipeline = sharp(source, { failOn: 'error' })
.rotate()
.resize({ width, withoutEnlargement: true });
if (format === 'avif') {
return {
body: await pipeline.avif({ quality: 50, effort: 4 }).toBuffer(),
contentType: 'image/avif',
};
}
return {
body: await pipeline.webp({ quality: 72, effort: 4 }).toBuffer(),
contentType: 'image/webp',
};
}effort: 4 is the inflection point: effort: 9 can double encode time for sub-2% byte savings — a terrible p99 trade.
True Cost per 100k Variants: The Model
Workload: 8.33M deliveries/month, 92% edge hit ratio → 667k cold transformations/month, 45KB average AVIF → 375 GB/month egress.
function costInputs(deliveries: number, hitRatio: number, avgKb: number) {
if (hitRatio < 0 || hitRatio > 1) throw new RangeError('hitRatio must be in [0,1]');
return {
misses: Math.round(deliveries * (1 - hitRatio)),
egressGb: (deliveries * avgKb) / 1024 / 1024,
};
}
console.log(costInputs(8_330_000, 0.92, 45));
// { misses: 666400, egressGb: 357.5 }| Vendor | Metering unit | Direct monthly (list, planning) | Direct $/100k cold variants | Fully-loaded $/100k |
|---|---|---|---|---|
| Cloudinary | credits (transforms + bandwidth) | ~$150–460 | ~$22–69 | ~$22–69 |
| Imgix | master images + bandwidth | ~$150–350 (plan floor dominant) | ~$0 marginal | ~$22–52 amortized |
| Cloudflare Images | stored + delivered | ~$84 | ~$12.50 | ~$12.50 |
| Next.js self-hosted | egress + compute | ~$30 direct | ~$4.50 direct | ~$500–700 (0.3 FTE platform) |
The ranking inverts between the direct and fully-loaded columns, and that inversion is the whole point. Self-hosting looks 20× cheaper until you price the on-call rotation, the cache-handler migrations, the sharp memory tuning, and the region failover. Cloudinary looks expensive until you subtract the DAM and the internal pipeline you did not build.
All figures are order-of-magnitude planning estimates derived from published list pricing at the time of writing. Verify against current vendor pricing pages before you commit to a contract — the ranking is durable, the absolute numbers are not.
Operational Checklist for Production Portfolio Image Pipelines
- Content-hash variant URLs and serve
Cache-Control: public, max-age=31536000, immutable. - Add
CDN-Cache-Control: public, max-age=31536000, stale-while-revalidate=86400so browsers and edges diverge. - Keep
deviceSizesto five entries. Cap DPR at 2, and only below 1440w. - Encode format in the URL (
f_auto/auto=format/format=auto); never rely onVary: Acceptalone. - Run a post-deploy prewarm for the top 200 variants per region, and fail the deploy if the warm job errors.
- Track edge hit ratio as an SLO (
cache-statusjoin), not as a dashboard curiosity. - Sign delivery URLs on metered plans to prevent hotlink-driven spend.
- Strip EXIF and color profiles (
cs=strip,metadata: false) on every pipeline. - Assert the variant budget in CI so cardinality cannot drift silently.
Pitfalls That Will Blow Your p99 (and Your Invoice)
- `Vary: Accept` fragmentation. Normalize format into the URL instead.
- DPR 3 on a 2560w ladder. Generates 7680px-wide assets nobody can perceive and nobody prewarms.
- On-demand AVIF for the long tail. A 2% long-tail miss rate at 900ms encode time sets your p99 permanently.
- Analytics query strings appended to image URLs — every session creates a new cache key.
- The optimizer-behind-CDN loop. Next.js
/_next/imagebehind a CDN that does not key onAcceptandwidthwill thrash both caches. - Base64 LQIP inline in HTML. It inflates the document and delays LCP; use a CSS background or a tiny hashed URL.
- Assuming p99 ≈ 2 × p50. For image workloads p99/p50 is routinely 8–15× because the distribution is bimodal.
- Ignoring `Content-Type` on the response. An AVIF served as
image/jpegfails to decode in some clients and will not show up in your error budget.
Sources & Technical References
- RFC 9111 — HTTP Caching. https://www.rfc-editor.org/rfc/rfc9111
- RFC 9211 — The Cache-Status HTTP Response Header Field. https://www.rfc-editor.org/rfc/rfc9211
- RFC 9110 §12.5.5 —
Varyheader semantics. https://www.rfc-editor.org/rfc/rfc9110 - MDN — HTTP Content Negotiation. https://developer.mozilla.org/en-US/docs/Web/HTTP/Content_negotiation
- MDN —
Cache-Control. https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control - Cloudinary — Image transformation URL reference. https://cloudinary.com/documentation/image_transformations
- Cloudinary — Pricing and credit model. https://cloudinary.com/pricing
- Imgix — Rendering API reference (
auto,cs,ar,fit). https://docs.imgix.com/apis/rendering - Imgix — Pricing (master images + bandwidth). https://imgix.com/pricing
- Cloudflare — Images documentation (named vs flexible variants). https://developers.cloudflare.com/images/
- Cloudflare — Images pricing. https://developers.cloudflare.com/images/pricing/
- Cloudflare — Cache keys and Cache Rules. https://developers.cloudflare.com/cache/
- Next.js —
next/imagecomponent API reference. https://nextjs.org/docs/app/api-reference/components/image - Next.js — Custom image loader configuration. https://nextjs.org/docs/app/api-reference/components/image#loader
- web.dev — Serve responsive images. https://web.dev/articles/serve-responsive-images
- AOMedia — AVIF specification. https://aomediacodec.github.io/av1-avif/
- libvips / sharp — API reference and performance notes. https://sharp.pixelplumbing.com/
- Grafana k6 — Thresholds and arrival-rate executors. https://grafana.com/docs/k6/latest/using-k6/thresholds/
- HTTP Archive — Web Almanac, Media chapter. https://almanac.httparchive.org/en/2022/media
Discussion (0)
Continue Reading
More production-grade engineering deep dives and architectural guides

1B Spans/Day in Go: OTel SDK vs. eBPF vs. Hand-Rolled Tracing — Measured p99, CPU per Span, and Real Cost per 1M Spans
A billion spans a day sounds like a hyperscaler number — it is really just ~2,900 RPS. We benchmarked the OpenTelemetry Go SDK, eBPF auto-instrumentation, and a hand-rolled arena tracer on the same gRPC service to separate instrumentation CPU cost from the number that actually destroys observability budgets: storage and indexing per 1M spans.

Kubernetes Autoscaling at 10k Pods: Karpenter vs Cluster Autoscaler vs CAST AI, Measured
We ran identical HPA-driven load ramps from 2,000 to 10,000 pods across three EKS clusters to measure p99 scale-up latency, bin-packing waste, spot interruption recovery, and true cost per 1,000 pod-hours. Here is the methodology, the raw numbers, and the architectural reasons behind them.

Component Testing at 1M Runs/Month: Playwright CT vs Cypress CT vs Vitest Browser Mode — Measured p95, Flake Rate, and True CI Cost
We executed one million component test runs across Playwright CT, Cypress CT, and Vitest browser mode on identical hardware and measured p95 wall time, first-attempt flake rate, and throughput. The surprising result: CI compute is under 0.5% of your real bill — flake triage is the other 98%.