Integración20 min de lectura

curl --compressed: qué clientes HTTP no piden compresión

curl, Wget 1.x, urllib, node:http, PHP curl, Guzzle y java.net.http.HttpClient no envían Accept-Encoding (o envían identity) por defecto. Soluciones y bytes comprobados.

En esta página

curl no envía la cabecera Accept-Encoding a menos que le pases --compressed. GNU Wget 1.x y urllib.request de Python envían Accept-Encoding: identity, y node:http de Node, ext-curl de PHP, Guzzle y java.net.http.HttpClient de Java no envían ninguna cabecera. Un servidor que solo comprime cuando se le pide devuelve entonces el texto completo sin comprimir, y cada uno de esos bytes pasa por tu proxy. Requests, httpx, aiohttp, Scrapy, el fetch integrado de Node, axios, got, Go y los navegadores ya piden compresión: con ellos no hay nada que activar, y como mucho un decodificador opcional de br recorta algo más en algunas páginas.

Cada valor por defecto de abajo lo registró un laboratorio local que ipvolt ejecutó el 27 de septiembre de 2026, con las versiones que se indican bajo cada tabla. Los resultados son sintéticos: un servidor y un proxy en 127.0.0.1 sirvieron una página de prueba local, así que ningún recuento de bytes de esta guía procede de un sitio público ni de la factura de un proveedor.

Quién pide compresión por defecto

No envían Accept-Encoding, o envían identity

Un servidor que solo comprime cuando se le pide envía a estos clientes el texto sin comprimir. Sin cabecera: curl, node:http, node:https, PHP ext-curl, Guzzle, Laravel Http, Java HttpClient. identity: Wget, urllib, http.client.

ClienteSolución
curlAñade --compressed
WgetAñade --compression=auto
urllib, http.clientUsa Requests o httpx
node:http, node:httpsUsa fetch, got o axios
PHP ext-curlEstablece CURLOPT_ACCEPT_ENCODING en ''
Guzzle, Laravel HttpEstablece decode_content en 'gzip'
Java HttpClientEnvía Accept-Encoding: gzip y descomprime con GZIPInputStream

Versiones ejecutadas: curl 8.7.1, 8.22.0; Wget 1.25.0; urllib.request, http.client (Python 3.13.15, 3.14.7); node:http, node:https (Node.js 22.23.3, 24.21.0, 26.10.0); PHP 8.5.8 ext-curl (libcurl 8.20.0); Guzzle 8.2.0, Laravel Http 13.33.0; Java 25.0.4.1 HttpClient.

  • curl: con -H 'Accept-Encoding: gzip' puesto a mano, la cabecera se envía, pero el cuerpo sigue comprimido.
  • Wget: con --compression=auto envió gzip y decodificó la respuesta.
  • urllib.request y http.client devuelven los cuerpos comprimidos tal como llegaron.
  • node:http y node:https devuelven los cuerpos comprimidos tal como llegaron.
  • PHP ext-curl: con CURLOPT_ACCEPT_ENCODING en '', este libcurl envió deflate, gzip, br, zstd. Una cabecera puesta a mano se envía, pero no se decodifica.
  • Laravel Http: withOptions(['decode_content' => 'gzip']).
  • Java HttpClient nunca decodifica: envuelve el cuerpo en GZIPInputStream cuando la respuesta viene en gzip.

Ya piden compresión

No hay nada que cambiar: estos clientes piden compresión y decodifican la respuesta.

ClienteCabecera enviada
RequestsPython 3.13.15: gzip, deflate; Python 3.14.7: gzip, deflate, zstd
httpxgzip, deflate
aiohttpPython 3.13.15: gzip, deflate; Python 3.14.7: gzip, deflate, zstd
Scrapygzip, deflate, br, zstd
fetch integrado de Node.jsNode.js 22.23.3/24.21.0: br, gzip, deflate; Node.js 26.10.0: br, gzip, deflate, zstd
Go net/httpgzip

Versiones ejecutadas: Requests 2.34.2; httpx 0.28.1; aiohttp 3.14.3; Scrapy 2.19.0; fetch integrado (Node.js 22.23.3, 24.21.0, 26.10.0); Go 1.27.1 net/http.

  • Requests: brotli añade br; en Python 3.13, backports.zstd añade zstd.
  • httpx: brotli añade br y zstandard añade zstd.
  • aiohttp: Brotli añade br; en Python 3.13, backports.zstd añade zstd.
  • fetch integrado con URL http://: gzip, deflate.
  • Go: si pones la cabecera tú mismo, la decodificación se desactiva.

Si tu cliente ya pide compresión, confírmalo con la comprobación de bytes de más abajo, y lee la sección sobre cabeceras copiadas del navegador antes de añadir ninguna cabecera a mano. La compresión no aporta nada con imágenes, vídeo y otros contenidos que ya vienen comprimidos, uses el cliente que uses.

Cada fila es una ejecución del laboratorio, no una lectura del código fuente: 567 celdas registradas, ninguna marcada como leída en el código fuente. tables.md enumera los registros que respaldan cada fila. Aquí, «Wget» significa GNU Wget 1.x. GNU Wget2 pide por defecto todas las codificaciones con las que se compiló (wget.c), y Fedora 40 y posteriores instalan wget2 como el comando wget (cambio de Fedora); esto se ha leído en el código fuente, y el laboratorio no ejecutó Wget2.

curl: sin cabecera hasta que pasas --compressed

Un curl URL a secas no envía ningún Accept-Encoding. En libcurl, CURLOPT_ACCEPT_ENCODING vale NULL por defecto, lo que «hace que libcurl no envíe una cabecera Accept-Encoding: y no descomprima automáticamente el contenido recibido», y la herramienta curl solo lo cambia con --compressed. Ninguna de las dos compilaciones de curl del laboratorio envió la cabecera, ni en directo, ni a través de un proxy http://, ni por CONNECT. curl#11091 muestra lo mismo con una captura de nc de curl 7.88.1.

--compressed pide todas las codificaciones que tu compilación sabe decodificar y después decodifica la respuesta:

  • El curl 8.7.1 del sistema en macOS, compilado solo con zlib, envió deflate, gzip.
  • El curl 8.22.0 de Homebrew, compilado con brotli y zstd, envió deflate, gzip, br, zstd.

Para saber qué pedirá el tuyo, lee la línea Features: de curl -V: libz significa gzip y deflate, brotli añade br y zstd añade zstd.

Tres detalles deciden si sirve de algo:

  • Es «una petición, no una orden; el servidor puede entregar los datos comprimidos o no» (página del manual). curl no informa de ningún error cuando un servidor la ignora y no tiene ninguna opción para fallar en ese caso (curl#7516), así que comprueba los bytes como se muestra más abajo.
  • Un -H 'Accept-Encoding: gzip' puesto a mano se envía, pero curl no decodifica la respuesta. En el laboratorio, curl imprimió el flujo gzip de 17.750 bytes en lugar de la página de 100.129 bytes. Usa --compressed, no una cabecera.
  • --no-compressed lo vuelve a desactivar, por ejemplo después de una línea --compressed en .curlrc.

Cuando el servidor del laboratorio envió br o zstd, que la compilación de macOS no sabe decodificar, curl --compressed se detuvo con el código de salida 61:

code
curl: (61) Unrecognized content encoding type. libcurl understands deflate, gzip content encodings.

La descompresión tiene su propio coste. La página del manual advierte de que «incluso transferencias diminutas podrían expandirse y generar una cantidad enorme de bytes» y sugiere --max-filesize. Esa opción detiene una transferencia que crece al descomprimirse con --compressed solo «desde 8.20.0» (página del manual), así que no protege al curl 8.7.1 del sistema en macOS. Ese límite está documentado; el laboratorio no lo probó.

Sin --compressed, un servidor que comprime de todos modos te deja con bytes gzip en bruto, como en curl#2836; es el conocido caso de la «salida binaria», y --compressed lo resuelve. Para las opciones de proxy en sí, consulta usar un proxy con curl.

Python: urllib envía identity; Requests, httpx, aiohttp y Scrapy comprimen

  • urllib.request y http.client envían Accept-Encoding: identity en CPython 3.13.15 y 3.14.7. http.client la añade salvo que pongas la tuya, con el comentario «no admitimos codificaciones como x-gzip o x-deflate» (client.py), y ninguno de los dos módulos decodifica la respuesta. En el laboratorio, urllib.request devolvió las respuestas gzip, br y zstd tal como llegaron; http.client, a través del cual urllib.request envía sus peticiones, solo se ejecutó con su cabecera por defecto, así que para http.client esto se basa en el código fuente. Poner la cabecera tú mismo solo te da bytes comprimidos que tendrás que decodificar a mano, así que mejor cambia de cliente. Un script que deba quedarse en la biblioteca estándar puede enviar Accept-Encoding: gzip y, cuando la respuesta traiga Content-Encoding: gzip, decodificar el cuerpo con gzip.decompress(); esa vía no se ejecutó en el laboratorio.
  • Requests 2.34.2 (con urllib3 2.8.0) toma su valor por defecto de urllib3 (utils.py): gzip, deflate en Python 3.13.15 y gzip, deflate, zstd en 3.14.7, donde urllib3 usa compression.zstd de la biblioteca estándar. Instalar brotli añade br; en 3.13, backports.zstd añade zstd. Desde urllib3 2.6.0, el paquete aparte zstandard ya no activa zstd (request.py; leído en el código fuente, no ejecutado). La parte del proxy está en configurar Python Requests.
  • urllib3 por sí solo, mediante urllib3.request() o un PoolManager, no añade ninguna cabecera de compresión. Su ruta de petición pasa a http.client (connection.py), lo que, según el código fuente, significa identity. Esto se ha leído en el código fuente; el laboratorio ejecutó Requests, no urllib3 solo.
  • aiohttp 3.14.3 envía gzip, deflate en 3.13.15 y gzip, deflate, zstd en 3.14.7; Brotli añade br, y en 3.13 backports.zstd añade zstd. Es el único cliente de Python de esta guía que lanza una excepción ante una codificación que no sabe decodificar; el mensaje exacto está más abajo.
  • Scrapy 2.19.0 envía gzip, deflate, br, zstd en ambas. Desde Scrapy 2.18.0, la compatibilidad con brotli y Zstandard es obligatoria, «así que br y zstd siempre se incluyen en la cabecera Accept-Encoding de las peticiones» (notas de la versión).

httpx y zstd en Python 3.14

httpx 0.28.1 envía gzip, deflate en las dos versiones de Python. Añade br con brotli, y zstd solo con el paquete de terceros zstandard; a diferencia de Requests y aiohttp, no usa el zstd de la biblioteca estándar de Python 3.14 (_decoders.py). La documentación de HTTPX instala ambos decodificadores con pip install "httpx[brotli,zstd]". Nada de eso hace falta para la compresión en sí, porque gzip siempre se pide.

Esto importa cuando un servidor envía una codificación que httpx no sabe decodificar: httpx devuelve los bytes comprimidos y no lanza nada. En Python 3.14.7 sin zstandard, una respuesta zstd llegó a tu código como el flujo zstd de 19.298 bytes. La configuración del proxy está en la guía de proxies async en HTTPX.

Node.js: el fetch integrado comprime, node:http no

El fetch integrado (undici) elige la cabecera según el esquema de la URL y la línea de versiones (código fuente, v26.10.0; v24.21.0):

  • URL https://: br, gzip, deflate en Node.js 22.23.3 y 24.21.0, y br, gzip, deflate, zstd en 26.10.0.
  • URL http://: gzip, deflate en las tres.
  • Cualquier petición con cabecera Range: identity en las tres.

Las tres decodificaron gzip y br. Donde difieren es en zstd, incluido el zstd que un servidor envía sin que se lo pidan:

  • El fetch de Node.js 22.23.3 no tiene decodificador de zstd y devolvió los bytes comprimidos sin ningún error (código fuente).
  • El fetch de Node.js 24.21.0 decodificó zstd que no había pedido. Un cuerpo formado por cuatro tramas zstd concatenadas volvió solo con la primera trama: 25.033 de 100.129 bytes, sin ningún error. axios 1.20.0 y got 16.0.0 también devolvieron solo la primera trama en Node.js 22.23.3 y 24.21.0.
  • Node.js 26.10.0 decodificó las cuatro tramas con fetch, axios y got.

node:http y node:https no envían Accept-Encoding en ninguna de las tres versiones y nunca decodifican; el cliente HTTP del núcleo de Node no pone la cabecera (_http_client.js). La documentación de zlib de Node muestra la vía manual: pon la cabecera tú mismo y pasa la respuesta por un descompresor. Cambiar a fetch, got o axios requiere menos código.

El fetch integrado frente al paquete node-fetch

El paquete de npm node-fetch es un cliente aparte con sus propios valores por defecto (node-fetch#1556). En Node.js 24.21.0, node-fetch 3.3.2 envió gzip, deflate, br y node-fetch 2.7.0 envió gzip,deflate, y ambos decodificaron la respuesta. axios 1.20.0 envía gzip, compress, deflate, br y solo añade zstd con transitional: { advertiseZstdAcceptEncoding: true } (http.js). got 16.0.0 envió gzip, deflate, br, zstd en las tres versiones (index.ts). Ninguno necesita cambios. El dispatcher de proxy para el fetch integrado se explica en usar un proxy con fetch de Node.js.

Go, Wget, PHP y Java

Go net/http solo pide gzip

Go 1.27.1 envía Accept-Encoding: gzip, decodifica la respuesta y quita Content-Encoding y Content-Length de la respuesta que te entrega (transport.go). No envía la cabecera en peticiones HEAD, en peticiones con cabecera Range ni con DisableCompression: true. Dos consecuencias:

  • Poner Accept-Encoding tú mismo desactiva la decodificación: «si el usuario pidió gzip explícitamente, no se descomprime automáticamente». Con req.Header.Set("Accept-Encoding", "gzip"), el cliente Go del laboratorio recibió el flujo gzip de 17.750 bytes.
  • net/http no tiene decodificador de br ni de zstd. Un servidor que los envió sin que se los pidieran entregó bytes en bruto.

GNU Wget 1.x envía identity

Wget 1.25.0 envía Accept-Encoding: identity. Sobre --compression, el manual dice de «none» que «es el valor por defecto» (manual); la lista de .wgetrc de la misma página sigue llamando valor por defecto a auto, pero la ejecución del laboratorio coincide con none. Con --compression=auto, Wget envió gzip y decodificó gzip, y nada más: el br o el zstd enviados sin pedirlos llegaron en bruto. Wget2 se comporta de otra forma, como se indicó arriba.

PHP: Guzzle, Laravel Http y ext-curl no envían Accept-Encoding

  • PHP ext-curl (PHP 8.5.8 con libcurl 8.20.0) no envía nada, porque CURLOPT_ACCEPT_ENCODING «vale null por defecto» (manual de PHP). CURLOPT_ACCEPT_ENCODING => '' pide todas las codificaciones que admite el libcurl enlazado, deflate, gzip, br, zstd en esta compilación, y decodifica. Una línea Accept-Encoding en CURLOPT_HTTPHEADER se envía, pero no se decodifica.
  • Guzzle 8.2.0 quita la cabecera a propósito. Su handler de cURL activa todos los decodificadores y después añade una línea Accept-Encoding: vacía para que curl no envíe ninguna, con el comentario de que esto «se interpretará como 'Accept-Encoding: *'» (CurlFactory.php). El mismo código está en todas las etiquetas revisadas, de la 6.5.0 a la 8.2.0 (leído en el código fuente). Si das a decode_content una cadena, como 'decode_content' => 'gzip', Guzzle la envía como cabecera (Client.php); 'gzip, deflate, br, zstd' también funcionó con el handler de cURL. Por defecto, el handler de cURL sigue decodificando el gzip, br o zstd que un servidor envía sin que se lo pidan. El StreamHandler de Guzzle, que usa streams de PHP en lugar de ext-curl, decodificó gzip pero devolvió br y zstd en bruto.
  • Laravel Http (illuminate/http 13.33.0) construye un cliente Guzzle normal y hereda todo esto (PendingRequest.php). Tanto withOptions(['decode_content' => 'gzip']) como withHeaders(['Accept-Encoding' => 'gzip']) enviaron gzip y decodificaron la respuesta.

Java HttpClient nunca decodifica

java.net.http.HttpClient en Temurin 25.0.4.1 no envió Accept-Encoding y no decodificó una respuesta gzip que el servidor envió sin que se la pidieran. Para tener compresión, envía Accept-Encoding: gzip tú mismo y lee el cuerpo a través de java.util.zip.GZIPInputStream cuando la respuesta traiga Content-Encoding: gzip, como hace el cliente Java del laboratorio. No se ejecutó ningún otro cliente Java.

Accept-Encoding: identity, sin cabecera y salida ilegible

El RFC 9110 distingue tres estados de la petición:

  • Sin cabecera (curl, node:http, ext-curl, Guzzle, Java): «el agente de usuario considera aceptable cualquier codificación de contenido».
  • identity (urllib, Wget 1.x): «un sinónimo de 'sin codificación'».
  • Un valor vacío: «el agente de usuario no quiere ninguna codificación de contenido en la respuesta».

Así que el estándar permite a un servidor comprimir la respuesta a una petición sin cabecera, y «comprimir solo cuando se pide» es una práctica de los servidores, no una regla del RFC. La documentación de servidores y CDN describe esa práctica. Cloudflare elige gzip, Brotli, Zstandard o ninguna compresión «en función de» los valores de la cabecera accept-encoding de la petición, el plan y cualquier regla de compresión que coincida (Cloudflare, página actualizada el 17 de abril de 2026). mod_deflate de Apache envía Vary: Accept-Encoding para que el contenido comprimido no se «envíe a un cliente que no lo entenderá» (mod_deflate). El comentario de Guzzle se apoya en la lectura del RFC; con servidores que siguen esa práctica, sin cabecera llega el cuerpo sin comprimir. El servidor del laboratorio siguió la misma práctica. No se midió con qué frecuencia los servidores reales comprimen sin que se lo pidan.

Algunos lo hacen. gzip_static always de nginx sirve el archivo comprimido con gzip «sin comprobar si el cliente lo admite» (nginx), y curl#2836 documenta un host real que lo hacía. Cuando el servidor del laboratorio envió gzip sin que se lo pidieran, curl sin --compressed, Wget, urllib, node:http, PHP ext-curl, Java y Go con DisableCompression devolvieron todos el flujo gzip de 17.750 bytes tal como llegó; Guzzle y Laravel Http lo decodificaron. Si la respuesta de uno de esos clientes parece basura binaria, mira primero su Content-Encoding.

Comprueba los bytes en tu propio destino

Si aquí no hay un fragmento que cuente los bytes de tu cliente, ejecuta la comparación de curl que sigue con la misma URL y el mismo proxy, y compara su Content-Encoding con el de tu cliente. Esto mide la respuesta de curl; una codificación o respuesta distinta del servidor no mide los bytes de tu cliente.

Los fragmentos de curl, Requests, httpx y node-fetch-bytes.mjs de abajo imprimen los bytes del cuerpo en la red: el cuerpo de la respuesta tal como cruzó la red, antes de cualquier decodificación. No incluyen las cabeceras de respuesta, los registros TLS, el intercambio CONNECT ni los reintentos, así que no es lo que factura un proveedor. En ocho pares del laboratorio (tables.md los enumera), el mismo cliente descargó la página de prueba por CONNECT con y sin compresión, y esos extras añadieron de 2.343 a 3.520 bytes por respuesta en el tramo entre el proxy y el cliente. Redujeron la relación de compresión, por ejemplo de 5,19× en el cuerpo a 4,56× en el tramo del proxy con curl 8.22.0, pero no el ahorro: en cada uno de esos pares, el tramo del proxy se redujo en la diferencia del cuerpo más 87 bytes. Para medir una ejecución completa a través de tu proxy y convertirla con tu propia tarifa, usa Scrapescope.

El fragmento de Go imprime el tamaño decodificado y resp.Uncompressed, que indica si Go decodificó la respuesta, y el node-fetch-verify.mjs enlazado imprime la cabecera que envió fetch; ninguno de los dos imprime bytes en la red. Cada fragmento se ejecutó sin cambios en el laboratorio, salvo que https://example.com/ pasó a ser una URL de loopback, y las salidas que se muestran son de esas ejecuciones; además, el fragmento de Go se compiló con un archivo del laboratorio que confía en la CA del laboratorio, descrito en la sección de método. Pon tu proxy en PROXY_URL. Si necesitas confiar en una CA privada, usa el ajuste propio del cliente, como --cacert, NODE_EXTRA_CA_CERTS o tls.Config.RootCAs de Go; nunca desactives la verificación de certificados.

Comprobar con curl

sh
# Wire body bytes and the Content-Encoding the server chose (curl 7.84.0+ for %header{}).
curl -sS -o /dev/null -x "$PROXY_URL" \
  -w '%{size_download} %header{content-encoding}\n' https://example.com/
curl -sS -o /dev/null -x "$PROXY_URL" --compressed \
  -w '%{size_download} %header{content-encoding}\n' https://example.com/

Con el curl 8.22.0 de Homebrew contra la página de prueba del laboratorio, la primera línea imprimió 100129 y ninguna codificación, y la segunda 19298 zstd; el curl 8.7.1 del sistema en macOS imprimió 17750 gzip en la segunda línea. El size_download de curl es «el tamaño del cuerpo/los datos transferidos, sin incluir las cabeceras», y %header{} necesita curl 7.84.0 o posterior (write-out). La relación de compresión de esa página es el primer número dividido por el segundo.

Comprobar con Requests y httpx

python
import os

import requests

proxies = {"http": os.environ["PROXY_URL"], "https": os.environ["PROXY_URL"]}
with requests.get("https://example.com/", proxies=proxies, stream=True) as r:
    wire = r.raw.read(decode_content=False)  # body bytes as sent, still encoded
    print(r.headers.get("content-encoding"), len(wire))

Requests imprimió gzip 17750 en Python 3.13.15 y zstd 19298 en 3.14.7. En httpx, r.num_bytes_downloaded cuenta los bytes tal como se leyeron y len(r.content), el cuerpo decodificado:

python
import os

import httpx

with httpx.Client(proxy=os.environ["PROXY_URL"]) as client:
    r = client.get("https://example.com/")
    # wire body bytes (as received) vs decoded bytes your code sees
    print(r.headers.get("content-encoding"), r.num_bytes_downloaded, len(r.content))

Imprimió gzip 17750 100129 en Python 3.14.7, y zstd 19298 100129 con zstandard instalado. urllib nunca decodifica, así que la longitud de lo que lees de él ya es el cuerpo en la red.

Comprobar con Node.js

Para el fetch integrado, la entrada de resource timing que Node crea para la URL da el tamaño del cuerpo en la red:

js
// Wire body bytes of a built-in fetch, from Node's resource timing entry for the URL.
// Run: NODE_USE_ENV_PROXY=1 HTTPS_PROXY="$PROXY_URL" node node-fetch-bytes.mjs
import { setImmediate } from 'node:timers/promises';

const url = new URL('https://example.com/');
const res = await fetch(url);
const body = await res.arrayBuffer(); // already decoded by fetch

// Node adds the entry after the body has been read, so give the event loop a turn.
let entry;
for (let turn = 0; !entry && turn < 100; turn++) {
  await setImmediate();
  entry = performance.getEntriesByType('resource').findLast((e) => e.name === url.href);
}
performance.clearResourceTimings(); // the buffer stops at 250 entries unless you clear it

// encodedBodySize: the body as it crossed the network, before decoding (no headers, TLS or CONNECT)
console.log('content-encoding', res.headers.get('content-encoding'),
  'wire body bytes', entry?.encodedBodySize, 'decoded bytes', body.byteLength);

A través del proxy CONNECT del laboratorio imprimió content-encoding br wire body bytes 18451 decoded bytes 100129 en Node.js 22.23.3 y 24.21.0, y content-encoding zstd wire body bytes 19298 decoded bytes 100129 en 26.10.0, en cada caso lo mismo que había enviado el servidor del laboratorio. En otras 18 celdas del laboratorio con las tres versiones (http:// directo, https:// directo y a través del proxy CONNECT, cada una con y sin compresión), encodedBodySize coincidió siempre con los bytes del cuerpo que envió el servidor; tables.md enumera las lecturas. La entrada nunca existía justo después de leer el cuerpo, solo tras una vuelta del bucle de eventos; por eso el fragmento espera. Después, el fragmento borra las entradas, porque el búfer de resource timing de Node solo guarda 250 por defecto (observe.js). No uses transferSize en su lugar: fue encodedBodySize más 300 bytes fijos en todas las celdas, mientras que, a través del proxy, el tramo del cliente transportó de 3.431 a 3.520 bytes más que el cuerpo.

node-fetch-verify.mjs imprime la cabecera que envió fetch (sent accept-encoding: br, gzip, deflate, zstd en Node.js 26.10.0). Para node:https, que nunca decodifica, node-http-verify.mjs cuenta el cuerpo a medida que llega, que es el cuerpo en la red.

Comprobar con Go

go
// Did Go ask for gzip and decode it for you? resp.Uncompressed says so.
// Run: go run go-verify.go   (with PROXY_URL set in the environment)
package main

import (
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
)

func main() {
	proxy, err := url.Parse(os.Getenv("PROXY_URL"))
	if err != nil {
		panic(err)
	}
	t := http.DefaultTransport.(*http.Transport).Clone()
	t.Proxy = http.ProxyURL(proxy)
	resp, err := (&http.Client{Transport: t}).Get("https://example.com/")
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		panic(err)
	}
	// true: Go sent "Accept-Encoding: gzip" itself, received gzip and decoded it,
	// then removed Content-Encoding and set ContentLength to -1.
	fmt.Printf("uncompressed=%v content-encoding=%q content-length=%d decoded-bytes=%d\n",
		resp.Uncompressed, resp.Header.Get("Content-Encoding"), resp.ContentLength, len(body))
}

Con Go 1.27.1 imprimió uncompressed=true content-encoding="" content-length=-1 decoded-bytes=100129. resp.Uncompressed solo indica que Go decodificó la respuesta (response.go); la respuesta ya no lleva su tamaño en la red. Para contar los bytes en la red, pon la cabecera tú mismo, lo que deja el cuerpo comprimido, mídelo y después decodifícalo con compress/gzip.

A través de un proxy: CONNECT frente a forma absoluta

Con una URL https://, el cliente abre un túnel CONNECT y envía su petición dentro de TLS. Con TLS de extremo a extremo y sin interceptación TLS, el proxy no puede leer ni cambiar Accept-Encoding: retransmite la respuesta cifrada. El registrador del laboratorio no analizó las cabeceras dentro de los túneles CONNECT. Con una URL http://, la mayoría de los clientes envían la propia petición al proxy en forma absoluta, como GET http://host/path, así que el proxy lee todas las cabeceras: el proxy del laboratorio vio el deflate, gzip, br, zstd de curl 8.22.0 con --compressed, el identity de Wget y el gzip de Go. Un intermediario en esa ruta también podría reescribir la cabecera o el cuerpo. El proxy del laboratorio no hizo ninguna de las dos cosas, y no se probó ningún proxy real.

Dos clientes se comportaron de otra forma con URL http://:

  • El fetch integrado de Node.js 22.23.3 y 24.21.0, con NODE_USE_ENV_PROXY=1, envió las URL http:// por un túnel CONNECT. El registrador del laboratorio no analizó la cabecera interior, pero esa petición HTTP seguía en texto claro y el proxy podía inspeccionarla: CONNECT por sí solo no cifra el tráfico. Node.js 26.10.0 usó la forma absoluta.
  • El StreamHandler de Guzzle envió al proxy una línea de petición en forma de origen salvo que se activara request_fulluri, y el proxy del laboratorio la rechazó con un 400.

Lo que cuenta para la factura depende de la regla de cada proveedor. Decodo, por ejemplo, documenta el tráfico de descarga como «cada byte recibido del sitio web de destino, incluidas las cabeceras de respuesta y las cookies», dice que en HTTPS «los bytes que pasan por el túnel del proxy también cuentan para el uso de tráfico del proxy», y da un ejemplo en el que su propio endpoint de prueba consumió 0,85 kB de descarga por http:// frente a 3,96 kB por https:// (ayuda de Decodo, consultada el 26 de septiembre de 2026, página modificada el 23 de abril de 2026). Decodo no dice si cuenta los bytes comprimidos o los decodificados. Para HTTPS con TLS de extremo a extremo dentro de CONNECT, el proxy cuenta los bytes cifrados que cruzan el túnel, incluida la respuesta comprimida y la sobrecarga de TLS; eso es una inferencia, no una afirmación de Decodo. Las API de scraping y los desbloqueadores que envían la petición al destino por ti eligen sus propias cabeceras y miden el tráfico a su manera, así que consulta la documentación de tu proveedor.

Copiar el Accept-Encoding de un navegador

Puede que te tiente pegar en un scraper el Accept-Encoding: gzip, deflate, br, zstd de un navegador. Eso pide al servidor codificaciones que quizá tu cliente no sepa decodificar, y en varios clientes una cabecera puesta a mano además desactiva la decodificación. Con exactamente esa cabecera, el servidor del laboratorio eligió zstd:

  • curl (las dos compilaciones), urllib, node:http, Go, PHP ext-curl, HttpClient de Java y el StreamHandler de Guzzle devolvieron los bytes comprimidos: una respuesta normal con un cuerpo ilegible.
  • Requests y httpx hicieron lo mismo siempre que faltaba el decodificador correspondiente. En Python 3.13, Requests sin extras devolvió en bruto tanto zstd como br. En 3.14 decodificó zstd, pero devolvió br en bruto cuando el servidor envió br. httpx sin zstandard devolvió zstd en bruto en las dos versiones de Python, sin ninguna excepción.
  • El fetch de Node.js 22.23.3 devolvió zstd en bruto; 24.21.0 y 26.10.0 lo decodificaron.
  • El handler de cURL de Guzzle decodificó tanto zstd como br.
  • aiohttp lanzó una excepción siempre que le faltaba el decodificador: br sin Brotli, y zstd en Python 3.13 sin backports.zstd. En 3.14 decodificó zstd.

La solución es dejar que el cliente escriba la cabecera. Si quieres br o zstd, instala el paquete decodificador que usa tu cliente, como brotli, backports.zstd en Python 3.13 o httpx[brotli,zstd]; el cliente añade entonces el token por sí mismo.

aiohttp 3.14.3: Can not decode content-encoding: brotli (br). Please install `Brotli`

Cuando un servidor envía br y falta el paquete Brotli, aiohttp 3.14.3 lanza el primero de estos errores, tanto en Python 3.13.15 como en 3.14.7. El segundo es la versión para zstd, que se lanza en Python 3.13.15 sin backports.zstd (URL de loopback acortadas):

code
aiohttp.client_exceptions.ClientResponseError: 400, message='Can not decode content-encoding: brotli (br). Please install `Brotli`', url='…'
aiohttp.client_exceptions.ClientResponseError: 400, message='Can not decode content-encoding: zstandard (zstd). Please install `backports.zstd`', url='…'

El 400 es el estado propio de aiohttp para una respuesta que no puede decodificar; el servidor del laboratorio había respondido 200 (http_parser.py). Instalar Brotli, o backports.zstd para el segundo, lo resolvió en el laboratorio: aiohttp decodificó entonces br, y también zstd, incluido un cuerpo de cuatro tramas zstd. El mensaje más corto Can not decode content-encoding: br sale de otra ruta del mismo archivo: hay un decodificador instalado, pero el cuerpo no se pudo descomprimir.

Servidores que gestionan mal un zstd anunciado

Anunciar una codificación puede, por sí solo, romper una petición. OpenSearch 2.19.0 se colgaba cuando el Accept-Encoding de una petición incluía zstd, como ocurre con las compilaciones de curl con soporte de zstd. Se corrigió para 2.19.1 y 3.0.0 (OpenSearch#17339). Si un host agota el tiempo de espera solo con clientes que admiten zstd, una petición que pida solo gzip mostrará si el problema es la negociación de zstd.

Dónde la compresión no ayuda

  • Contenidos que ya vienen comprimidos, como imágenes, vídeo y archivos comprimidos. «Los archivos multimedia que ya están comprimidos, como las imágenes, no se benefician de la compresión HTTP» (Web Almanac 2021).
  • Respuestas que un servidor o una CDN dejan sin comprimir. Cloudflare, por ejemplo, documenta que solo comprime las respuestas 200, 403 y 404, con un tamaño mínimo de 48 bytes para gzip y de 50 bytes para Brotli y Zstandard (Cloudflare).
  • Peticiones Range y HEAD. Go no envía la cabecera en ninguna de las dos, y el fetch de Node.js envía identity siempre que hay una cabecera Range.
  • Subidas. --compressed y sus equivalentes actúan sobre las descargas; para los cuerpos de las peticiones «no hay una forma estándar de comprimir» (Everything curl).
  • Automatización de navegadores. Los navegadores negocian la compresión por sí mismos: MDN da gzip, deflate, br, zstd como valor típico de un navegador (MDN), y Chrome decodifica zstd por defecto desde la versión 123 (Chrome Platform Status), así que en Playwright o Puppeteer los bytes que recortar están en lo que descarga el navegador, no en esta cabecera.

Para las páginas que descargas una y otra vez, las peticiones condicionales son otra palanca: una respuesta 304 Not Modified no lleva ningún cuerpo (RFC 9110; monitorización con ETag).

Cuánto se ahorra

Solo los clientes de la primera tabla tienen una compresión que activar. Para ellos:

ahorro en USD ≈ páginas × bytes de texto sin comprimir por página × (1 − 1/relación) ÷ 10⁹ × tu precio en USD por GB

Toma la relación de compresión del paso de comprobación en tus propias páginas, como bytes sin compresión divididos por bytes con compresión, y el precio de tu propio plan.

La compresión solo baja la factura cuando pagas por byte. En un plan de tarifa plana por Mbps o por hilos, el precio no cambia, aunque por el canal caben más páginas comprimidas. Cuánto cuestan por GB los proxies residenciales ilimitados compara las dos formas de facturar.

Un ejemplo inventado, no una medición: 100.000 páginas de 100 KB (100.000 bytes) de HTML son 10 GB sin comprimir. Con una relación de 4, la compresión ahorra 7,5 GB; con una relación de 10, ahorra 9,0 GB. Con un precio inventado de 3 a 8 USD por GB, eso supone unos 22,50 a 72 USD por un rastreo de esas páginas.

En un cliente que ya pide compresión, activarla no ahorra nada, porque ya está activada. Un decodificador opcional de br para Requests, httpx o aiohttp puede recortar algo más en algunas páginas: en las 23 páginas de documentación del laboratorio, los cuerpos br sumaron 277.665 bytes frente a 325.873 con gzip, alrededor de un 15 % menos, pero en la página sintética br ocupó más que gzip (18.451 frente a 17.750 bytes).

Las relaciones de compresión del propio laboratorio no son una previsión. La página sintética de prueba se redujo 5,64× con gzip y 5,19× con zstd. En este corpus de 23 páginas de la documentación de CPython 3.14.7, los cuerpos sin comprimir ocupaban 6,67× su tamaño en gzip, 6,61× su tamaño en zstd y 7,83× su tamaño en br. Esas cifras describen solo esos archivos.

La fórmula cuenta bytes del cuerpo; en el laboratorio, las cabeceras, TLS y el intercambio CONNECT apenas cambiaron con la compresión, como muestra la sección de comprobación. Cómo convierte un proveedor los bytes en una factura (GB o GiB, mínimos, peticiones fallidas) lo cubre Scrapescope. El resultado es una estimación, no la factura de un proveedor.

Método, limitaciones y descargas

ipvolt ejecutó la matriz registrada de 567 celdas una sola vez, el 27 de septiembre de 2026, en macOS 15.7.4 (Apple M4 Pro, arm64): la preparación de 02:43 a 02:44 UTC y después toda la matriz de 02:44 a 02:45 UTC. Un servidor Python en 127.0.0.1 sirvió una página HTML sintética de 100.129 bytes, y en algunas celdas 23 páginas de la documentación de CPython 3.14.7, detrás de un proxy de reenvío en 127.0.0.1 que contaba bytes y atendía tanto peticiones en forma absoluta como CONNECT. Sin cabecera, el servidor enviaba la página sin comprimir; en caso contrario, elegía entre las codificaciones que el cliente enumeraba, con preferencia por zstd, después br, gzip y deflate. Los modos forzados enviaban gzip, br, zstd, cuatro tramas zstd o ninguna compresión, sin tener en cuenta la petición. Cada celda HTTPS confió en una CA local desechable mediante el ajuste propio del cliente, y las 21 celdas de control sin esa CA fallaron en la verificación del certificado. El fragmento de Go se compiló con un archivo del laboratorio que ponía esa CA en los RootCAs de http.DefaultTransport antes de que el fragmento lo clonara (su texto es LAB_TRUST_GO en snippets.py), para que la confianza no dependiera de SSL_CERT_FILE: go1.27.1 en macOS respeta esa variable salvo que se defina GODEBUG=x509sslcertoverrideplatform=0, como ocurre por defecto en un módulo que declara go 1.26. Una segunda ejecución a partir del archivo publicado, en un directorio vacío y con todas las descargas obtenidas de nuevo, coincidió en los 567 registros en todos los campos comparados (compare.py --strict).

Versiones: curl 8.7.1 (macOS, LibreSSL) y 8.22.0 (Homebrew, OpenSSL 3.6.4, brotli, zstd); GNU Wget 1.25.0; CPython 3.13.15 y 3.14.7 con Requests 2.34.2 (urllib3 2.8.0), httpx 0.28.1, aiohttp 3.14.3 y Scrapy 2.19.0; Node.js 22.23.3, 24.21.0 y 26.10.0 de nodejs.org, con las sumas de verificación comprobadas, con axios 1.20.0, got 16.0.0 y node-fetch 3.3.2 y 2.7.0; Go 1.27.1; PHP 8.5.8 (compilación estática, libcurl 8.20.0) con Guzzle 8.2.0 e illuminate/http 13.33.0; Temurin JDK 25.0.4.1.

El laboratorio no cubrió HTTP/2 ni HTTP/3 (el servidor solo ofrecía HTTP/1.1), compilaciones para Linux o Windows, navegadores, Wget2, urllib3 por sí solo, curl_cffi, Apache HttpClient, OkHttp, Symfony HttpClient, .NET HttpClient, reqwest de Rust, Net::HTTP o Faraday de Ruby, otras compilaciones de PHP o libcurl, ni versiones de Java posteriores a la 25. Los valores por defecto cambian de una versión a otra. Estas eran las versiones instaladas o vigentes cuando se ejecutó el laboratorio: el curl 8.7.1 del sistema en macOS es anterior a curl 8.22.0, la compilación estática de PHP iba tres versiones de parche por detrás de la 8.5.11 de php.net, y Java 25 es la versión LTS más reciente, no la versión de funcionalidades más reciente. Vuelve a ejecutar el laboratorio antes de fiarte de una fila.

Todos los archivos están bajo https://ipvolt.com/downloads/accept-encoding-defaults/:

Para reproducir toda la ejecución, descomprime el archivo, ejecuta ./setup.sh y después ./run.sh en harness/, y luego python3 ../compare.py ../results.json out/results.json. El README enumera lo que descarga la preparación y lo que necesita cada grupo de celdas. No se necesita ninguna cuenta de proxy.

ipvolt, que publica esta guía, es un servicio de proxies en desarrollo que tendrá precios por GB en su lanzamiento. Las mediciones usan un proxy de prueba local; los consejos sirven para cualquier proveedor, o para ninguno.

Si quieres enterarte de cuándo se abre el acceso a ipvolt, únete a la lista de acceso anticipado. Un correo cuando se abra el acceso. Nada más.

Fuentes y lecturas adicionales

Referencias técnicas usadas para esta guía. Consulta la documentación de tu versión instalada y la configuración compatible de tu proveedor.