wandres.dev
COMPRESIÓN · gzip, Brotli y Zstd

Los niveles de compresión y su coste en CPU

Por qué la relación entre nivel y tamaño tiene rendimientos decrecientes brutales, dónde está el punto óptimo para cada situación, y cómo medirlo en tu propio contenido.

⏱ 16 min

Todos los compresores exponen un nivel que va de rápido a pequeño, y la relación entre ambos extremos no es lineal ni de lejos: el último tramo de niveles multiplica el tiempo de compresión por un factor grande para arañar un porcentaje mínimo de tamaño. Saber dónde está el codo de esa curva para tu contenido es la diferencia entre una configuración razonable y una que quema CPU sin motivo.

🎯 Al terminar esta lección sabrás
  • Describir la forma de la curva entre nivel de compresión y tamaño resultante.
  • Distinguir el coste de comprimir del coste de descomprimir y quién paga cada uno.
  • Elegir el nivel adecuado según si el contenido es estático o dinámico.
  • Medir la curva sobre tu propio contenido en lugar de fiarte de valores genéricos.

La forma de la curva

Cuando dibujas tamaño resultante frente a nivel para cualquiera de los tres compresores, la curva tiene siempre la misma forma: baja deprisa al principio y se aplana. El tiempo de compresión, en cambio, crece de forma acelerada y en el tramo alto se dispara.

Estas son proporciones ilustrativas para Brotli sobre un archivo de texto web típico, normalizadas para ver la forma:

Nivel Tamaño relativo Tiempo relativo de compresión
1 1,00 1
4 0,93 3
5 0,91 5
6 0,90 9
9 0,88 30
11 0,85 150 a 400

Los números exactos varían con el contenido, y lo que no varía es la estructura: del nivel 1 al 5 ganas la mayor parte del tamaño por una fracción del coste; del 9 al 11 pagas un factor de cinco o diez en tiempo para ganar unos pocos puntos porcentuales.

Hay una segunda variable que interactúa con el nivel y que se olvida: el tamaño de la ventana. En Brotli, subir la ventana permite referenciar repeticiones más lejanas, lo cual ayuda mucho en archivos grandes y aumenta el consumo de memoria del compresor y del descompresor. En archivos pequeños no aporta nada, porque no hay distancia que recorrer.

Quién paga cada coste

Esta es la distinción que ordena toda la decisión.

El coste de comprimir lo paga tu servidor, y lo paga tantas veces como se genere la respuesta. Para contenido estático, es una vez en el proceso de construcción. Para contenido dinámico, es una vez por petición.

El coste de descomprimir lo paga el dispositivo del usuario, siempre, en cada respuesta. Y aquí hay una propiedad muy conveniente: el coste de descompresión depende poco del nivel usado al comprimir. Descomprimir un archivo comprimido con Brotli 11 cuesta aproximadamente lo mismo que descomprimir el mismo archivo con Brotli 5. Es decir, subir el nivel es gratis para el usuario.

De ahí se derivan las dos reglas fundamentales:

Para contenido estático, usa el nivel máximo. El coste lo pagas una vez en un servidor de construcción al que no le corre prisa, y el usuario recibe menos bytes sin pagar más CPU al descomprimir. No hay contrapartida.

Para contenido dinámico, usa un nivel medio. El coste de compresión se paga en cada petición y compite con la capacidad de tu servidor. Un nivel máximo en contenido dinámico puede añadir decenas de milisegundos al tiempo de respuesta, que se suman directamente al TTFB de cada usuario.

🛑
El nivel máximo en contenido dinámico es un antipatrón caro

Comprimir al máximo una respuesta generada por petición añade tiempo al TTFB de todos los usuarios y consume CPU de servidor que podrías dedicar a atender más peticiones. El ahorro de bytes es de unos pocos puntos porcentuales; el coste de latencia puede ser de decenas de milisegundos por respuesta. En una respuesta grande y un servidor cargado, comprimir al máximo puede tardar más de lo que se ahorra en transferencia, y entonces la optimización es netamente negativa.

El caso de la respuesta en streaming

Hay un matiz que cambia el cálculo y que conviene conocer. Si tu servidor envía la respuesta en fragmentos conforme la genera, la compresión también trabaja por fragmentos, y entonces el coste de comprimir se solapa con el de generar y con el de transferir en lugar de sumarse.

En ese escenario, el impacto de un nivel alto en el tiempo hasta el primer byte es menor, porque el primer fragmento se comprime y se envía enseguida. Sigue habiendo consumo de CPU, y ese sigue compitiendo con el resto de peticiones que atiende el servidor.

Hay un compromiso adicional aquí: comprimir por fragmentos pequeños empeora el ratio, porque el compresor no puede referenciar repeticiones más allá del fragmento actual con la misma eficacia. Los servidores exponen un tamaño de buffer para regularlo: fragmentos grandes comprimen mejor y retrasan el primer byte, fragmentos pequeños al revés.

Medir la curva en tu contenido

Los valores genéricos orientan; la medición decide. Este script produce la tabla para tus propios archivos.

#!/usr/bin/env bash
# Curva de nivel, tamano y tiempo para un fichero concreto.
FICHERO="$1"
ORIGINAL=$(wc -c < "$FICHERO")
echo "original: $ORIGINAL bytes"
printf "%-6s %-12s %-10s %-10s\n" nivel bytes ratio "ms"

for N in 1 4 5 6 9 11; do
  INICIO=$(date +%s%N)
  BYTES=$(brotli -q "$N" -c "$FICHERO" | wc -c)
  FIN=$(date +%s%N)
  MS=$(( (FIN - INICIO) / 1000000 ))
  RATIO=$(echo "scale=3; $ORIGINAL / $BYTES" | bc)
  printf "%-6s %-12s %-10s %-10s\n" "$N" "$BYTES" "$RATIO" "$MS"
done

Y la versión en Node para medir sobre el contenido real que genera tu aplicación, incluidas las respuestas de API:

import { brotliCompressSync, constants } from 'node:zlib';
import { performance } from 'node:perf_hooks';

function curva(buffer, niveles = [1, 4, 5, 6, 9, 11]) {
  return niveles.map((nivel) => {
    const t0 = performance.now();
    const salida = brotliCompressSync(buffer, {
      params: { [constants.BROTLI_PARAM_QUALITY]: nivel },
    });
    const ms = performance.now() - t0;
    return {
      nivel,
      bytes: salida.length,
      ratio: +(buffer.length / salida.length).toFixed(2),
      ms: +ms.toFixed(1),
    };
  });
}

const muestra = Buffer.from(JSON.stringify(await obtenerRespuestaTipica()));
console.table(curva(muestra));

Lo que buscas en esa tabla es el codo: el nivel a partir del cual el tiempo empieza a crecer más deprisa de lo que baja el tamaño. Para contenido dinámico, quédate justo antes del codo. Para contenido estático, el nivel máximo, sin más análisis.

Configuración recomendada

Resumen accionable por escenario.

Escenario Brotli gzip Zstandard
Estáticos precomprimidos en el build 11 9 19
HTML dinámico con streaming 4-5 6 9-12
Respuestas de API 4-5 6 9-12
Proxy inverso delante de un origen lento 4 5 6
Función en el borde con límite de CPU 4 6 9

Y una comprobación que evita el fallo más tonto de todos: asegúrate de que el nivel que configuraste es el que se aplica. Algunos módulos y algunas capas de CDN tienen su propio valor por defecto que sobrescribe el tuyo, o recomprimen la respuesta que ya llegaba comprimida. La forma de verificarlo es comparar el tamaño del cuerpo que sale de tu origen con el que llega al cliente.

La compresión dinámica al máximo nivel es una de las pocas optimizaciones que puede degradar el servicio bajo carga, y el fallo aparece justo cuando peor viene

Todas las técnicas de este track tienen un coste acotado y previsible salvo esta. Comprimir al máximo en contenido dinámico consume CPU proporcional al tráfico, y la CPU de un servidor es un recurso con un comportamiento no lineal muy desagradable: mientras hay margen, todo va bien y la latencia no se mueve; cuando se agota, la latencia se dispara y aparecen las colas. El resultado es una configuración que funciona perfectamente en pruebas de carga moderadas y que colapsa exactamente en el pico de tráfico, cuando más peticiones hay que comprimir. Y el diagnóstico es difícil porque el síntoma es una subida general de latencia sin ningún endpoint concreto culpable. La defensa es doble y ninguna de las dos cuesta trabajo: fija el nivel dinámico en un valor medio y no lo toques, y mide siempre el tiempo de compresión como una métrica de servidor propia, no como parte del tiempo total de respuesta. Si esa métrica sube con la carga, tienes un problema estructural que ninguna optimización de frontend va a compensar.