Comprender y optimizar precios de redes de entrega de contenido
Una mayor tasa de aciertos de caché puede reducir tu factura del origen y dejar casi sin cambios los cargos de solicitudes y entrega de la CDN. Para optimizar los costos de la CDN, separa primero esas métricas de facturación. Luego comprueba que tu política de caché y las URL de imágenes sirvan los bytes correctos antes de aumentar su tiempo de validez.

¿Qué es una CDN?
Una CDN recibe solicitudes cerca de tus usuarios y sirve una respuesta almacenada en caché o la obtiene de tu origen. Un acierto de caché evita parte del trabajo en origen, pero el nodo de borde sigue procesando la solicitud y entregando bytes al usuario. La caché del navegador puede evitar por completo esa solicitud de red; la caché del borde no puede hacerlo.
Panorama de los modelos de precios de CDN
Los modelos de precios pueden combinarse: un plan con cobro por uso puede cobrar tanto las solicitudes como la transferencia, con cargos adicionales por cómputo y seguridad. Empieza por tu carga de trabajo y luego aplica las cuotas incluidas, los tramos, las regiones y los compromisos mínimos del plan seleccionado.
| Modelo | Qué revisar antes de estimar |
|---|---|
| Plan mensual de tarifa fija | Cuotas incluidas de solicitudes y transferencia, tráfico elegible y qué ocurre al superarlas |
| Entrega con cobro por uso | Bytes entregados por región de facturación, cantidad de solicitudes, cuotas gratuitas y límites de los tramos |
| Contrato con compromiso de gasto | Gasto mínimo, productos incluidos, excesos de consumo y si caduca el compromiso no utilizado |
Al 8 de octubre de 2026, los precios de Amazon CloudFront incluyen planes de tarifa fija sin cargos por exceso de consumo junto con precios de pago por uso. Los planes de tarifa fija siguen teniendo cuotas de uso; lee las condiciones del plan antes de considerarlos una entrega ilimitada. Consulta los planes de Cloudflare, los precios de Fastly y los precios de Akamai para conocer paquetes o solicitar cotizaciones. Si el enlace de precios de Akamai no está disponible, su página de Download Delivery ofrece un contacto de ventas. El precio más bajo que anuncia un proveedor no es una estimación de tu carga de trabajo.
Factores clave que influyen en los costos de una CDN
Mantén cuatro partidas en tu hoja de cálculo, incluso si un plan agrupa algunas de ellas:
| Categoría de costo | Datos de la carga de trabajo | Cómo estimar |
|---|---|---|
| Solicitudes | Solicitudes mensuales de los usuarios por categoría facturable | Divide las solicitudes facturables por la unidad de la tarifa y multiplica por esa tarifa |
| Transferencia | Bytes entregados a los usuarios por región de facturación | Convierte los bytes a la unidad GB del proveedor y luego aplica las cuotas incluidas y los tramos |
| Origen | Obtenciones desde el origen, bytes transferidos y trabajo de la aplicación | Aplica por separado las tarifas de almacenamiento, solicitudes, transferencia de salida y cómputo del origen |
| Cómputo y extras | Invocaciones de funciones, duración o unidades de CPU, transformaciones, registros y seguridad | Usa la métrica de facturación de cada producto habilitado; resta solo la cuota incluida que le corresponda |
Ancho de banda y transferencia de datos
Un mes hipotético con 10 millones de solicitudes de 200 KB en promedio entrega 2.000 GB, usando KB y GB decimales. Con tarifas inventadas de 0,08 $/GB y 1 $/millón de solicitudes, el costo es de 160 $ por transferencia más 10 $ por solicitudes, antes de cuotas incluidas, costos del origen, cómputo, impuestos o compromisos. Son ejemplos aritméticos, no cotizaciones de proveedores. Usa los bytes reales de transferencia facturable en lugar del tamaño de los archivos sin comprimir y verifica si el proveedor define los GB como decimales o binarios.
Si el 80 % de esas solicitudes acierta en la caché del borde, un modelo simple con una obtención por cada fallo de caché deja 2 millones de obtenciones desde el origen. Eso no reduce los 10 millones de solicitudes de los usuarios ni los 2.000 GB entregados por la CDN. La revalidación, la agrupación de solicitudes, las solicitudes de rangos y los tamaños desiguales de los objetos pueden cambiar la estimación del origen; contrástala con sus registros en lugar de multiplicar todos los bytes por una tasa de aciertos de solicitudes.
Distribución geográfica
Agrupa la entrega por las regiones de facturación del proveedor, no por la ubicación del origen. Tanto los precios de CloudFront con cobro por uso como los precios de Fastly tienen tablas regionales de transferencia. Una tarifa promedio ponderada por región requiere conocer la distribución de tu tráfico. Trasladar un origen no cambia por sí solo la métrica de facturación del borde al usuario.
Funcionalidades y seguridad
Enumera los productos habilitados junto a la estimación de entrega. Las reglas de WAF, los registros, la optimización de imágenes y TLS pueden estar incluidos, cobrarse por uso o contratarse por separado. Revisa también los cargos del origen: actualmente CloudFront no cobra la transferencia desde orígenes de AWS a CloudFront, pero eso no elimina todos los cargos por almacenamiento o solicitudes de la aplicación.
Cómputo en el borde y funcionalidades modernas
Generar de antemano las variantes de imágenes populares traslada el trabajo de codificación a una compilación o tarea de procesamiento. Añade costos de almacenamiento y compilación, así que mídelo junto con el ahorro en entrega. El cómputo en el borde tiene métricas de facturación específicas de cada producto: Fastly enumera solicitudes y milisegundos de vCPU, mientras que otros productos usan invocaciones o duración, y los planes pueden incluir cómputo. Usa la métrica de facturación del producto elegido.
El siguiente laboratorio local comprueba la selección de URL, las cabeceras de caché del origen,
la compresión y el monitoreo. No despliega una CDN ni mide la tasa de aciertos de caché de un
proveedor. Usa Bash, Node.js 24.15.0 o una versión posterior
con mantenimiento de Node 24, o Node 26.5.0 o posterior de la rama Node 26, junto con
Corepack instalado por separado y cURL. El ejemplo usa la
eliminación nativa de tipos de TypeScript y archivos .mts para mantener
explícito el comportamiento de los módulos. Las versiones de Yarn y las dependencias se fijan
más abajo; Node 24.15.0, 26.5.0 y 26.8.1 son las versiones probadas, no versiones mínimas
intrínsecas de la API. Sharp genera imágenes sintéticas, no un pipeline de imágenes de producción.
Pega esta configuración en un directorio donde no exista cdn-cost-lab.
El subshell conserva el directorio y las opciones de la shell desde la que lo ejecutas,
y los comandos se detienen si ocurre un fallo:
(
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
)
Si la instalación falla después de crear el directorio, consérvalo y vuelve a intentarlo con
el bloque siguiente. No vuelvas a ejecutar la creación del proyecto sobre él. Guarda los
siguientes archivos dentro de cdn-cost-lab.
(cd cdn-cost-lab && corepack yarn install)
Guarda esto como prepare-demo.mts. Crea un PNG verde transparente y un JPEG rojo
opaco con el mismo nombre base, además de sus dos anchos en cada formato declarado. Mantener
la extensión de origen en el directorio de variantes evita que las dos identidades entren en
conflicto. El script rechaza un directorio dist existente; una
compilación fallida puede dejar resultados parciales, así que usa un laboratorio nuevo para
otra compilación.
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`)
Guarda esto como variants.mts. Solo las dos fuentes públicas declaradas tienen
variantes. Un parámetro de consulta desconocido devuelve null: podría
seleccionar una versión, un recorte o un objeto firmado diferente. Solo se pueden eliminar
w y el parámetro utm_source, que se sabe que es
inocuo en este laboratorio. Amplía esa lista solo después de comprobar el contrato del origen.
Los anchos desconocidos usan 1024; se rechazan los parámetros de ancho duplicados.
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()
}
Esto selecciona una URL, no una operación de codificación. Tu plantilla o el código que llama
al auxiliar debe solicitar la URL devuelta. Un resultado null significa
que este auxiliar no tiene una variante segura; no es un permiso para enviar un formato rechazado.
La alternativa en PNG conserva la transparencia. En producción, publica solo variantes que
existan y se hayan comprobado, y versiona sus URL cada vez que cambien sus bytes.
Servir distintos formatos desde una URL requiere una configuración de caché compatible.
Vary for Images de Cloudflare
requiere una regla de variantes configurada y está disponible en Pro, Business y Enterprise,
pero no en Free. Vary: Accept por sí solo no lo habilita. Las URL con formato
explícito separan las representaciones, pero tu CDN aún debe permitir almacenar esas respuestas
en caché.
Implementar una caché eficaz
Un tiempo de validez de un año para immutable solo es seguro si la URL cambia
cada vez que cambian los bytes. El nombre del archivo JavaScript del laboratorio contiene un
hash de su contenido. Las imágenes sin versión se mantienen vigentes durante una hora y pueden
servirse desactualizadas hasta un día durante la revalidación, así que no se garantiza que su
reemplazo llegue a los clientes en una hora. Nunca sobrescribas una URL que incluya un hash de
contenido con bytes diferentes.
Decide si se puede almacenar en caché según la ruta, no la extensión. /account/avatar.jpg
y /api/private.css pueden contener datos específicos de cada usuario. Guarda esto
como server.mts; solo los puntos de montaje públicos reemplazan la política
predeterminada no-store. Las rutas de cuenta son demostraciones sin
autenticación, no una implementación de autenticación. El laboratorio escucha solo en loopback.
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())
Desde dentro de cdn-cost-lab, genera los datos de prueba e inicia el origen.
PORT=0 elige un puerto sin usar; copia la URL del origen que se imprime
para usarla en la siguiente terminal. Detén el servidor en primer plano con Ctrl+C.
node prepare-demo.mts && PORT=0 node server.mts
En una segunda terminal dentro del laboratorio, asigna a DEMO_ORIGIN esa URL
impresa. Ejecuta este programa complementario para seleccionar y obtener una variante WebP
pequeña; la fuente PNG y el ancho permanecen en su ruta:
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
Deberías obtener /images/w/320/logo.png/image.webp, image/webp y una cantidad
positiva de bytes. Eso demuestra que el objeto seleccionado es accesible; inspecciona sus píxeles
cuando reemplaces esta demostración con tu propia compilación. Solicita la ruta del activo con
hash que imprime la compilación para inspeccionar immutable. Estos comandos
muestran las políticas para objetos sin versión, privados e inexistentes; usa el mismo
DEMO_ORIGIN:
curl -fsSI "$DEMO_ORIGIN/assets/app.js" &&
curl -fsSI "$DEMO_ORIGIN/account/avatar.jpg" &&
curl -sSI "$DEMO_ORIGIN/assets/absent.js"
La primera respuesta tiene max-age=3600, stale-while-revalidate=86400; la segunda y la respuesta 404 tienen
no-store. Estas cabeceras del origen no demuestran que una CDN desplegada
las respete. Comprueba sus reglas de caché, las cookies, el manejo de la autorización y los
registros de estado de caché antes de habilitar la caché compartida.
Monitoreo y optimización del rendimiento
Una sonda útil debe rechazar respuestas que no sean 2xx, leer el cuerpo completo y limitar el
tiempo de toda la solicitud. Guarda esto como edge-monitor.mts. Espera cinco
minutos entre muestras secuenciales y cancela una obtención activa o un temporizador de espera
con Ctrl+C. Su tiempo límite de cinco segundos incluye la transferencia del cuerpo, así que un
activo grande en una conexión lenta puede fallar aunque la transferencia siga avanzando.
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)
Ejecuta node edge-monitor.mts "$DEMO_ORIGIN/assets/app.js". Una línea de éxito incluye HTTP 200, los milisegundos
transcurridos, los bytes y cache:unknown para este origen local. Los parámetros
de consulta se omiten de los registros porque las URL pueden contener tokens firmados. La sonda
mide el recorrido de un proceso, no la latencia de los usuarios ni una tasa global de aciertos de
caché. La función fetch de Node descomprime los cuerpos, por lo que esta cantidad de bytes no
corresponde a los bytes comprimidos transmitidos por la red ni a una métrica de facturación.
La cabecera cf-cache-status es específica de Cloudflare; adáptala a tu proveedor.
Cada ejecución de la sonda añade tráfico que puede contabilizarse como solicitudes y transferencia.
Estrategias para optimizar los costos de una CDN
Usa la hoja de cálculo para elegir el cambio: las representaciones más pequeñas reducen los bytes entregados; la caché del borde reduce el trabajo en origen; la caché del navegador reduce las solicitudes de red repetidas. Verifica un cambio con la métrica de facturación correspondiente en lugar de asumir que las cuatro categorías de costo disminuyen juntas.
Minimizar las transferencias de datos innecesarias
Para probar la compresión en el origen, añade esto a server.mts inmediatamente
después de const app = express() y luego reinicia el servidor con
PORT=0 node server.mts. La importación permanece en el ámbito del módulo. Es una
alternativa para cuando no se proporciona compresión en otro punto de tu recorrido de entrega.
import compression from 'compression'
app.use(compression({ level: 6, threshold: 0 }))
Solicita /assets/app.js con Accept-Encoding: gzip y comprueba
Content-Encoding, la cantidad de bytes del cuerpo comprimido y los bytes
descomprimidos comparándolos con el original. Guarda esto como check-compression.mts
y ejecuta node check-compression.mts "$DEMO_ORIGIN" después de actualizar DEMO_ORIGIN
con la URL impresa del servidor reiniciado. Lee bytes HTTP sin procesar porque fetch los
descomprimiría por ti:
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`)
No vuelvas a comprimir con gzip archivos JPEG, WebP o AVIF solo porque sean grandes. El
middleware de compresión filtra por tipo de contenido y
respeta no-transform; la configuración de compresión de tu CDN puede ser
diferente. Este laboratorio no determina el ahorro por compresión de tus activos de producción.
Aprovechar la caché de forma eficaz
Normaliza solo los parámetros de consulta cuya irrelevancia para el objeto se haya demostrado.
Aquí, w=small se convierte en un segmento de ruta y
utm_source se puede descartar; version,
crop y los tokens firmados no se pueden descartar. El auxiliar rechaza
esas entradas desconocidas en lugar de agruparlas bajo otro objeto. Monitorea por separado la
tasa de aciertos por solicitudes y la tasa de aciertos por bytes, e inspecciona qué variantes o
valores de consulta causan fallos de caché antes de cambiar su clave.
Conclusión
Aplica a la tabla de tarifas elegida los datos de un mes sobre solicitudes, regiones, transferencia y origen; luego compara con otra estimación tras el cambio que hayas medido. Si tu aplicación necesita un paso de procesamiento de archivos durante la compilación, Transloadit puede proporcionar esa parte del flujo de trabajo; la política de caché y la facturación del proveedor siguen necesitando sus propias comprobaciones.
