Understanding and optimizing content delivery network pricing
A higher cache hit ratio can reduce your origin bill while leaving CDN request and delivery charges almost unchanged. To optimize CDN costs, separate those meters first. Then check that your cache policy and image URLs serve the right bytes before increasing their lifetime.

What is a CDN?
A CDN receives requests near your users and either serves a cached response or fetches it from your origin. A cache hit avoids some origin work, but the edge still handles the request and delivers bytes to the user. Browser caching can avoid that network request entirely; edge caching cannot.
Overview of CDN pricing models
Pricing models can overlap: a metered plan may charge for both requests and transfer, with separate fees for compute and security. Start with your workload, then apply the selected plan’s allowances, tiers, regions, and minimum commitments.
| Model | What to check before estimating |
|---|---|
| Flat monthly plan | Included request and transfer allowances, eligible traffic, and what happens above them |
| Metered delivery | Delivered bytes by billing region, request counts, free allowances, and tier boundaries |
| Committed contract | Minimum spend, included products, overages, and whether unused commitment expires |
As of October 8, 2026, Amazon CloudFront pricing includes flat-rate plans with no overage charges alongside pay-as-you-go pricing. Flat-rate plans still have usage allowances; read the plan conditions before treating them as unlimited delivery. Consult Cloudflare plans, Fastly pricing, and Akamai pricing for packages or quotes. If the Akamai pricing link is unavailable, its Download Delivery page has a sales contact. A provider’s cheapest headline price is not a workload estimate.
Key factors influencing CDN costs
Keep four lines in your worksheet, even when a plan bundles some of them:
| Cost bucket | Workload input | How to estimate |
|---|---|---|
| Requests | Monthly viewer requests by billable category | Billable requests divided by the rate’s unit, multiplied by that rate |
| Transfer | Bytes delivered to viewers by billing region | Convert bytes to the provider’s GB unit, then apply allowances and tiers |
| Origin | Origin fetches, transferred bytes, and application work | Apply the origin’s storage, request, egress, and compute rates separately |
| Compute and extras | Function invocations, duration or CPU units, transforms, logs, and security | Use each enabled product’s meter; subtract only its applicable allowance |
Bandwidth and data transfer
A hypothetical month with 10 million requests averaging 200 KB delivers 2,000 GB, using decimal KB and GB. At invented rates of $0.08/GB and $1/million requests, that is $160 for transfer plus $10 for requests, before allowances, origin costs, compute, taxes, or commitments. These are arithmetic examples, not provider quotes. Use actual billable transfer bytes rather than uncompressed file sizes, and verify whether the provider defines GB as decimal or binary.
If 80% of those requests hit the edge cache, a simple one-fetch-per-miss model leaves 2 million origin fetches. It does not reduce the 10 million viewer requests or 2,000 GB delivered by the CDN. Revalidation, request collapsing, range requests, and uneven object sizes can change the origin estimate; reconcile it with origin logs instead of multiplying all bytes by a request hit ratio.
Geographic distribution
Group delivery by the provider’s billing regions, not the origin’s location. Both metered CloudFront pricing and Fastly pricing have regional transfer tables. A regional blended rate needs your traffic mix. Moving an origin does not by itself change the edge-to-viewer meter.
Feature sets and security
List enabled products beside the delivery estimate. WAF rules, logging, image optimization, and TLS may be bundled, metered, or separately contracted. Also check origin-side charges: CloudFront currently waives transfer from AWS origins to CloudFront, but that does not waive every storage or application request charge.
Edge computing and modern features
Prebuilding popular image variants moves encoding work into a build or processing job. It adds storage and build cost, so measure those alongside delivery savings. Edge compute has product-specific meters: Fastly lists requests and vCPU-milliseconds, while other products use invocations or duration, and plans may bundle compute. Use the chosen product’s meter.
The following local lab checks URL selection, origin cache headers, compression, and monitoring.
It does not deploy a CDN or measure a provider’s cache hit rate. Use Bash,
Node.js 24.15.0 or a later
maintained Node 24 release, or Node 26.5.0 or later in Node 26, plus separately installed
Corepack and cURL. The example uses native TypeScript
stripping and .mts files to keep module behavior explicit. Yarn and dependency versions are pinned
below; Node 24.15.0, 26.5.0, and 26.8.1 are the tested versions, not intrinsic API minimums. Sharp
generates synthetic images, not a production image pipeline.
Paste this setup in a directory where cdn-cost-lab does not exist. The subshell preserves your
caller’s directory and options, and the commands stop on failure:
(
for tool in node corepack curl mkdir; do
command -v "$tool" >/dev/null || { printf 'Missing prerequisite: %s\n' "$tool" >&2; exit 1; }
done
node -e 'const [major, minor] = process.versions.node.split(".").map(Number); if (!(major === 24 && minor >= 15 || major === 26 && minor >= 5)) { console.error("Use Node 24.15+ or 26.5+ within those majors"); process.exit(1) }' &&
mkdir cdn-cost-lab && cd cdn-cost-lab &&
printf '%s\n' '{"private":true,"type":"module","packageManager":"yarn@4.12.0","dependencies":{"compression":"1.8.2","express":"5.2.1","negotiator":"1.1.0","sharp":"0.35.3"}}' > package.json &&
printf '%s\n' 'nodeLinker: node-modules' 'enableGlobalCache: false' > .yarnrc.yml &&
printf '' > yarn.lock &&
corepack yarn install
)
If installation fails after creating the directory, retain it and retry with
the block below. Do not rerun project creation over it. Save the following files inside
cdn-cost-lab.
(cd cdn-cost-lab && corepack yarn install)
Save this as prepare-demo.mts. It creates a transparent green PNG and an opaque red JPEG with the
same basename, plus their two widths in each declared format. Keeping the source extension in the
variant directory prevents the two identities from colliding. The script refuses an existing
dist directory; a failed build can leave partial output, so use a fresh lab for another build.
import { createHash } from 'node:crypto'
import { mkdir, writeFile } from 'node:fs/promises'
import sharp from 'sharp'
await mkdir('dist')
await mkdir('dist/assets', { recursive: true })
const script = 'console.log("cdn demo")\n'.repeat(1024)
const hash = createHash('sha256').update(script).digest('hex').slice(0, 8)
await writeFile(`dist/assets/app.${hash}.js`, script)
await writeFile('dist/assets/app.js', script)
for (const [source, formats, background] of [
['logo.png', ['png', 'avif', 'webp'], { r: 0, g: 180, b: 0, alpha: 0.5 }],
['logo.jpg', ['jpeg', 'avif', 'webp'], { r: 200, g: 0, b: 0, alpha: 1 }],
]) {
for (const width of [320, 1024]) {
const directory = `dist/images/w/${width}/${source}`
await mkdir(directory, { recursive: true })
for (const format of formats) {
const extension = format === 'jpeg' ? 'jpg' : format
await sharp({ create: { width, height: width / 2, channels: 4, background } })
.toFormat(format)
.toFile(`${directory}/image.${extension}`)
}
}
}
console.log(`/assets/app.${hash}.js`)
Save this as variants.mts. Only the two declared public sources have variants. An unknown query
parameter returns null: it might select a different version, crop, or signed object. Only w and
this lab’s known inert utm_source parameter can be removed. Expand that list only after checking
the origin’s contract. Unknown widths use 1024; duplicate width parameters are refused.
import Negotiator from 'negotiator'
const WIDTHS = new Map([['small', '320'], ['large', '1024']])
const PUBLISHED = new Map([
['/images/logo.png', new Map([['image/png', 'png'], ['image/avif', 'avif'], ['image/webp', 'webp']])],
['/images/logo.jpg', new Map([['image/jpeg', 'jpg'], ['image/avif', 'avif'], ['image/webp', 'webp']])],
])
export function buildVariantUrl(requestUrl, accept) {
const url = new URL(requestUrl)
const formats = PUBLISHED.get(url.pathname)
if (!formats) return null
for (const key of url.searchParams.keys()) {
if (key !== 'w' && key !== 'utm_source') return null
}
if (url.searchParams.getAll('w').length > 1) return null
// The source format wins wildcard ties; q=0 excludes a format.
const accepted = new Negotiator({ headers: { accept: accept ?? '*/*' } })
.mediaType([...formats.keys()])
const format = formats.get(accepted)
if (format === undefined) return null
const width = WIDTHS.get(url.searchParams.get('w')) ?? WIDTHS.get('large')
const name = url.pathname.slice('/images/'.length)
url.pathname = `/images/w/${width}/${name}/image.${format}`
url.search = ''
url.hash = ''
return url.toString()
}
This selects a URL, not an encoding operation. Your template or caller must request the returned
URL. A null result means this helper has no safe variant; it is not permission to send a rejected
format. A PNG fallback retains transparency. In production, publish only variants that exist and
have been checked, and version their URLs whenever their bytes change.
Serving different formats from one URL requires a compatible cache configuration. Cloudflare’s
Vary for Images
requires a configured variants rule and is available on Pro, Business, and Enterprise, not Free.
Vary: Accept alone does not enable it. Explicit format URLs separate representations, but your
CDN still needs to allow caching those responses.
Implementing effective caching
A one-year immutable lifetime is safe only when the URL changes whenever the bytes change. The
lab’s JavaScript filename contains a content hash. The unversioned images get an hour of freshness
and up to a day of stale serving during revalidation, so replacement is not guaranteed to reach
clients within an hour. Never overwrite a fingerprinted URL with different bytes.
Decide cacheability from the route, not the extension. /account/avatar.jpg and /api/private.css
can contain per-user data. Save this as server.mts; only the public mounts override the default
no-store policy. The account routes are unauthenticated demonstrations, not an authentication
implementation. The lab binds to loopback only.
import express from 'express'
const FINGERPRINTED = /\.[0-9a-f]{8,}\.(?:css|js|jpe?g|png|gif|webp|avif|woff2?)$/
function cacheControlFor(filePath) {
return FINGERPRINTED.test(filePath)
? 'public, max-age=31536000, immutable'
: 'public, max-age=3600, stale-while-revalidate=86400'
}
const app = express()
app.use((_req, res, next) => {
res.setHeader('Cache-Control', 'no-store')
next()
})
const publicFiles = {
setHeaders: (res, filePath) => res.setHeader('Cache-Control', cacheControlFor(filePath)),
}
app.use('/assets', express.static('dist/assets', publicFiles))
app.use('/images', express.static('dist/images', publicFiles))
app.get('/account/avatar.jpg', (_req, res) => res.type('jpg').send('per-user bytes'))
app.get('/api/private.css', (_req, res) => res.type('css').send(':root{--brand:rebeccapurple}'))
app.get('/api/failure.css', (_req, _res, next) => next(new Error('Demo failure')))
app.use((_req, res) => res.status(404).send('Not found'))
app.use((_error, _req, res, _next) => {
res.setHeader('Cache-Control', 'no-store')
res.status(500).send('Request failed')
})
const port = Number(process.env.PORT ?? 3000)
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('Invalid PORT')
const server = app.listen(port, '127.0.0.1', (error) => {
if (error) {
console.error(`Listen failed: ${error.code}`)
process.exitCode = 1
return
}
console.log(`http://127.0.0.1:${server.address().port}`)
})
process.once('SIGINT', () => server.close())
process.once('SIGTERM', () => server.close())
From inside cdn-cost-lab, build the fixtures and start the origin. PORT=0 chooses an unused
port; copy the printed origin URL for the next terminal. Stop the foreground server with Ctrl+C.
node prepare-demo.mts && PORT=0 node server.mts
In a second terminal inside the lab, set DEMO_ORIGIN to that printed URL. Run this counterpart to
select and fetch a small WebP variant; the PNG source and width remain in its path:
DEMO_ORIGIN='http://127.0.0.1:REPLACE_WITH_PRINTED_PORT'
node --input-type=module - "$DEMO_ORIGIN" <<'NODE'
import { buildVariantUrl } from './variants.mts'
const url = buildVariantUrl(`${process.argv[2]}/images/logo.png?w=small`, 'image/webp')
if (url === null) throw new Error('No acceptable variant')
const response = await fetch(url)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const bytes = new Uint8Array(await response.arrayBuffer())
console.log(new URL(url).pathname, response.headers.get('content-type'), bytes.length)
NODE
Expect /images/w/320/logo.png/image.webp, image/webp, and a positive byte count. That proves the
selected object is reachable; inspect its pixels when replacing this demo with your own build.
Request the hashed asset path printed by the build to inspect immutable. These commands show the
unversioned, private, and missing-object policies; use the same DEMO_ORIGIN:
curl -fsSI "$DEMO_ORIGIN/assets/app.js" &&
curl -fsSI "$DEMO_ORIGIN/account/avatar.jpg" &&
curl -sSI "$DEMO_ORIGIN/assets/absent.js"
The first response has max-age=3600, stale-while-revalidate=86400; the second and the 404 have
no-store. These origin headers do not prove that a deployed CDN honors them. Check its cache
rules, cookies, authorization handling, and cache-status logs before enabling shared caching.
Performance monitoring and optimization
A useful probe must reject non-2xx responses, read the whole body, and bound the complete request.
Save this as edge-monitor.mts. It waits five minutes between sequential samples and cancels an active
fetch or waiting timer on Ctrl+C. Its five-second timeout includes body transfer, so a large asset
on a slow connection can fail despite making progress.
import { setTimeout as sleep } from 'node:timers/promises'
async function probeEdge(url, timeoutMs = 5000, signal) {
const start = performance.now()
const deadline = AbortSignal.timeout(timeoutMs)
const response = await fetch(url, {
signal: signal ? AbortSignal.any([signal, deadline]) : deadline,
})
try {
if (!response.ok) return { url, ok: false, status: response.status }
let bytes = 0
if (response.body) {
for await (const chunk of response.body) bytes += chunk.byteLength
}
return {
url, ok: true, status: response.status, bytes,
duration: performance.now() - start,
cache: response.headers.get('cf-cache-status') ?? 'unknown',
}
} finally {
if (!response.bodyUsed) await response.body?.cancel()
}
}
function formatProbe(result) {
const { origin, pathname } = new URL(result.url)
if (!result.ok) return `${origin}${pathname} FAIL ${result.error ?? `HTTP ${result.status}`}`
return `${origin}${pathname} ${result.status} ${result.duration.toFixed(0)}ms ${result.bytes}B cache:${result.cache}`
}
async function monitorEdge(url, signal, intervalMs = 300_000) {
while (!signal.aborted) {
const result = await probeEdge(url, 5000, signal)
.catch((error) => ({ url, ok: false, error: error.name }))
if (signal.aborted) break
console.log(formatProbe(result))
await sleep(intervalMs, undefined, { signal }).catch((error) => {
if (error.name !== 'AbortError') throw error
})
}
}
const url = process.argv[2]
if (!url) throw new Error('Usage: node edge-monitor.mts URL')
const controller = new AbortController()
process.once('SIGINT', () => controller.abort())
process.once('SIGTERM', () => controller.abort())
await monitorEdge(url, controller.signal)
Run node edge-monitor.mts "$DEMO_ORIGIN/assets/app.js". A successful line includes HTTP 200,
elapsed milliseconds, bytes, and cache:unknown for this local origin. Query parameters are omitted
from logs because URLs can contain signed tokens. The probe measures one process’s path, not user
latency or a global cache hit ratio. Node’s fetch decodes compressed bodies, so this byte count is
not compressed wire bytes or a billing meter. The cf-cache-status header is Cloudflare-specific;
adapt it to your provider. Every probe adds traffic that may count toward requests and transfer.
Strategies for optimizing CDN costs
Use the worksheet to choose the change: smaller representations reduce delivered bytes; edge caching reduces origin work; browser caching reduces repeat network requests. Verify one change against the corresponding meter rather than assuming that all four cost buckets fall together.
Minimizing unnecessary data transfers
To test origin compression, add this to server.mts immediately after const app = express(), then
restart the server using PORT=0 node server.mts. The import stays at module scope. This is a
fallback when compression is not already provided elsewhere in your delivery path.
import compression from 'compression'
app.use(compression({ level: 6, threshold: 0 }))
Request /assets/app.js with Accept-Encoding: gzip and check Content-Encoding, the compressed
body’s byte count, and the decoded bytes against the original. Save this as check-compression.mts
and run node check-compression.mts "$DEMO_ORIGIN" after updating DEMO_ORIGIN to the restarted
server’s printed URL. It reads raw HTTP bytes because fetch would decode them for you:
import { strict as assert } from 'node:assert'
import { readFile } from 'node:fs/promises'
import { get } from 'node:http'
import { gunzipSync } from 'node:zlib'
const url = new URL('/assets/app.js', process.argv[2])
const response = await new Promise((resolve, reject) => {
get(url, { headers: { 'Accept-Encoding': 'gzip' }, signal: AbortSignal.timeout(5000) }, resolve)
.on('error', reject)
})
const chunks = []
for await (const chunk of response) chunks.push(chunk)
assert.equal(response.statusCode, 200)
assert.equal(response.headers['content-encoding'], 'gzip')
const encoded = Buffer.concat(chunks)
const original = await readFile('dist/assets/app.js')
assert.equal(Buffer.compare(gunzipSync(encoded), original), 0, 'Decoded bytes differ from the source')
console.log(`${encoded.length}B gzip, ${original.length}B decoded`)
Do not gzip JPEG, WebP, or AVIF again just because they are large. The
compression middleware filters by content type and
honors no-transform; your CDN’s compression configuration may differ. This lab does not establish
compression savings for your production assets.
Leveraging caching effectively
Normalize only query parameters proven irrelevant to the object. Here, w=small becomes a path
segment and utm_source can be discarded; version, crop, and signed tokens cannot. The helper
refuses those unknown inputs instead of collapsing them onto another object. Track request hit
ratio and byte hit ratio separately, and inspect which variants or query values cause misses before
changing the cache key.
Conclusion
Take a month of request, region, transfer, and origin data to the chosen rate card, then compare another estimate after your measured change. If your application needs a build-time file-processing step, Transloadit can supply that part of the workflow; cache policy and provider billing still need their own checks.
