Nuestro interés: somos Geonode y vendemos proxies, y la automatización de navegador es lo más hambriento de ancho de banda que puedes hacer a través de uno. Un navegador headless descarga cada imagen, fuente, script y precarga de vídeo, así que ejecutar chromedp por tráfico medido cuesta más o menos un orden de magnitud más que peticiones HTTP crudas a las mismas páginas. Hay una sección sobre recortar eso, y la técnica ahorrará más que elegir un proveedor más barato. La configuración del proxy son tres líneas y tiene una trampa de verdad, que también se cubre.
Qué es chromedp
El proyecto se describe como "a faster, simpler way to drive browsers supporting the Chrome DevTools Protocol in Go without external dependencies".
Esa última cláusula es el argumento de venta. Selenium necesita un binario de driver emparejado con la versión del navegador; Playwright envía su propio runtime. chromedp habla el DevTools Protocol directo por un websocket, así que un binario Go más una instalación de Chrome es todo el despliegue.
Es licencia MIT y se mantiene activo: la versión 0.15.1 salió en abril de 2026, con commits hasta julio, comprobado en septiembre de 2026.
La instalación no da que hablar:
go get -u github.com/chromedp/chromedp
Los bindings de protocolo generados viven en un paquete compañero, github.com/chromedp/cdproto, al que acudes cuando necesitas algo que la API de alto nivel no envuelve.
El modelo de context
La parte que hay que entender primero, porque todo lo demás sigue de ella.
chromedp usa context.Context para dos trabajos a la vez: cancelación, como Go siempre hace, y llevar los handles del navegador y de la pestaña. Ese doble propósito es por lo que la configuración se ve como se ve.
ctx, cancel := chromedp.NewContext(context.Background())
defer cancel()
var title string
err := chromedp.Run(ctx,
chromedp.Navigate("https://example.com"),
chromedp.Text("h1", &title, chromedp.NodeVisible),
)
El primer NewContext asigna un navegador. Los contexts siguientes derivados de él crean pestañas nuevas en el mismo navegador, que es cómo ejecutas varias páginas sin pagar el arranque del navegador una y otra vez:
browserCtx, cancelBrowser := chromedp.NewContext(context.Background())
defer cancelBrowser()
tabCtx, cancelTab := chromedp.NewContext(browserCtx)
defer cancelTab()
Cancelar cierra cosas. Cancelar un context de pestaña cierra la pestaña; cancelar el del navegador cierra el navegador. El defer cancel() no es contabilidad opcional: omitirlo filtra un proceso Chrome.
Los timeouts se componen a la manera ordinaria de Go:
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
Dos errores del propio FAQ del proyecto merecen conocerse de antemano.
"Executing an action without Run results in 'invalid context'." El FAQ explica que "by default, a chromedp context does not have an executor, however one can be specified manually if necessary". Las actions no se ejecutan solas: son valores que Run realiza.
"I'm seeing 'context canceled' errors." El FAQ lo atribuye a perder la conexión: "when the connection to the browser is lost, chromedp cancels the context, and it may result in this error. This occurs, for example, if the browser is closed manually, or if the browser process has been killed or otherwise terminated." Así que un error context canceled a menudo significa que Chrome murió, no que saltó el timeout: conviene distinguirlo antes de subir el timeout.
Actions
Run toma una secuencia de actions y las ejecuta en orden. Las comunes cubren la mayor parte del trabajo.
err := chromedp.Run(ctx,
chromedp.Navigate("https://example.com/search"),
chromedp.WaitVisible(`input[name="q"]`),
chromedp.SendKeys(`input[name="q"]`, "golang"),
chromedp.Click(`button[type="submit"]`, chromedp.NodeVisible),
chromedp.WaitVisible(`.results`),
chromedp.Text(`.results`, &results, chromedp.NodeVisible),
)
Extraer de varios elementos usa Nodes o Evaluate:
var links []string
err := chromedp.Run(ctx,
chromedp.Navigate(url),
chromedp.Evaluate(`[...document.querySelectorAll('a')].map(a => a.href)`, &links),
)
Evaluate ejecuta JavaScript en la página y desempaqueta el resultado en un valor Go, que a menudo es el camino más corto para cualquier cosa que involucre varios elementos a la vez. El valor debe ser serializable en JSON.
Para actions que devuelven varios valores, el FAQ da el wrapper:
chromedp.Run(ctx, chromedp.ActionFunc(func(ctx context.Context) error {
_, err := domain.SomeAction().Do(ctx)
return err
}))
ActionFunc también es cómo bajas a llamadas crudas de cdproto para lo que la API de alto nivel no cubre: poner cookies, interceptar peticiones de red, emular un dispositivo. Esa escotilla está disponible para todo el DevTools Protocol, que es una superficie grande.
Esperar bien
La diferencia entre un scraper fiable y uno inestable, y el error es siempre el mismo.
No hagas sleep. chromedp.Sleep(3*time.Second) existe, tienta, y o es demasiado corto — fallos intermitentes en un día lento — o demasiado largo, perdiendo tiempo en cada ejecución. Suele ser ambas cosas, en máquinas distintas.
Espera lo que te importa:
chromedp.WaitVisible(`.results`, chromedp.ByQuery)
chromedp.WaitNotVisible(`.spinner`)
chromedp.WaitReady(`#content`)
WaitVisible espera a que el elemento exista y sea visible; WaitReady espera a que exista en el DOM. Para contenido cargado tras una interacción, visible suele ser la condición correcta.
Para una condición que ningún selector expresa, haz poll en la página:
chromedp.Poll(`document.querySelectorAll('.item').length >= 20`, nil)
Esa es la respuesta a "espera hasta que la lista termine de cargar", que ninguna espera basada en elemento puede expresar.
Acota siempre la espera con un timeout de context. Un WaitVisible sobre un selector que nunca coincidirá bloquea hasta que el context expire, y sin timeout eso es para siempre.
Ejecutar headless, y en Docker
Chrome corre headless por defecto. El FAQ responde la primera pregunta que tiene la gente: "By default, Chrome is run in headless mode. See DefaultExecAllocatorOptions, and an example to override the default options."
Para verlo trabajar mientras desarrollas:
opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.Flag("headless", false),
)
allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(), opts...)
defer cancelAlloc()
ctx, cancel := chromedp.NewContext(allocCtx)
defer cancel()
NewExecAllocator es donde pones las flags de línea de comandos de Chrome, y es la capa por encima del context del navegador.
Para contenedores, la recomendación del proyecto es concreta: "The simplest way is to run the Go program that uses chromedp inside the chromedp/headless-shell image. That image contains headless-shell, a smaller headless build of Chrome, which chromedp is able to find out of the box."
Conviene seguirla. Montar un Chrome que funcione en un contenedor a mano significa perseguir bibliotecas compartidas y paquetes de fuentes que faltan, y el resultado es más grande que la imagen hecha para eso.
Un comportamiento específico de Linux del FAQ, que sorprende a quien ejecuta Chrome aparte: "On Linux, chromedp is configured to avoid leaking resources by force-killing any started Chrome child processes. If you need to launch a long-running Chrome instance, manually start Chrome and connect using RemoteAllocator."
RemoteAllocator se conecta a un navegador ya en marcha por su endpoint websocket, que es el patrón para un pool compartido o un navegador en un contenedor separado.
Usar un proxy
Tres líneas, y una trampa.
opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.ProxyServer("http://proxy.example.com:9000"),
)
allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(), opts...)
defer cancelAlloc()
ctx, cancel := chromedp.NewContext(allocCtx)
defer cancel()
ProxyServer pone la flag --proxy-server de Chrome.
La trampa es la autenticación. La flag --proxy-server de Chrome no admite credenciales: una URL con usuario y contraseña no autentica. Chrome responde a un desafío de proxy mostrando un diálogo, y un navegador headless no tiene a nadie que lo rellene.
Dos salidas, en orden de preferencia.
Usa una lista blanca de IP. Si la dirección de origen es estable, regístrala con el proveedor y tira las credenciales. Es la respuesta más limpia y quita el problema en vez de rodearlo.
Maneja el evento de autenticación. chromedp puede responder a la petición de autenticación del DevTools Protocol vía fetch.Enable con handleAuthRequests, suministrando credenciales por programa. Es más código, y es la vía cuando la dirección no es fija.
Luego verifica que funcionó, porque un proxy mal configurado en un navegador es silencioso:
var ip string
err := chromedp.Run(ctx,
chromedp.Navigate("https://api.ipify.org"),
chromedp.Text("body", &ip, chromedp.NodeVisible),
)
Ejecútalo con y sin la opción de proxy. Si la dirección no cambia, Chrome no lo está usando, y nada te lo habrá dicho. Para trabajo geo-dirigido, ve más allá y confirma que el contenido regionalmente distinto de verdad difiere, pues la dirección es la parte fácil. Es el patrón de fallo silencioso que describimos en por qué importa probar proxies.
Haz coincidir el locale con el país de salida de paso. Una salida alemana con cabecera de idioma en-US y zona horaria de Londres es una combinación que ningún visitante real produce, y muchos sitios usan locale con independencia de la dirección:
chromedp.Flag("lang", "de-DE"),
Recortar ancho de banda
La sección que más dinero ahorra, y aplica a cualquier automatización de navegador.
Una página que es 200 KB de HTML puede ser 4 MB cuando se ha descargado cada imagen, fuente, script de rastreo y precarga de vídeo. A tarifas residenciales de 0,79 $/GB — nuestras cifras, comprobadas en la página de precios en septiembre de 2026 — esa diferencia es todo el presupuesto.
Bloquea tipos de recurso que no necesitas. Con fetch.Enable y un listener de request-paused, aborta peticiones de imágenes, medios y fuentes:
chromedp.ListenTarget(ctx, func(ev interface{}) {
if e, ok := ev.(*fetch.EventRequestPaused); ok {
go func() {
c := chromedp.FromContext(ctx)
execCtx := cdp.WithExecutor(ctx, c.Target)
switch e.ResourceType {
case network.ResourceTypeImage, network.ResourceTypeMedia, network.ResourceTypeFont:
_ = fetch.FailRequest(e.RequestID, network.ErrorReasonBlockedByClient).Do(execCtx)
default:
_ = fetch.ContinueRequest(e.RequestID).Do(execCtx)
}
}()
}
})
Eso recorta de forma rutinaria la mayor parte del tráfico y acelera las ejecuciones como efecto secundario.
Reutiliza el navegador, crea pestañas. Arrancar el navegador es caro; una pestaña es barata. Para una pasada por muchas páginas, asigna una vez y deriva contexts de pestaña.
Y pregúntate si necesitas un navegador. Si el contenido está en el HTML inicial, una petición HTTP simple cuesta una fracción y corre mucho más rápido. Mira el código fuente de la página antes de recurrir a la automatización: el reflejo de renderizarlo todo es la fuente más común de coste innecesario en este terreno.
Depurar cuando nada funciona
La automatización de navegador falla de forma opaca: un selector que nunca coincide y una página que nunca carga producen el mismo timeout. Una secuencia fija resuelve la mayor parte.
Enciende el navegador y mira. El diagnóstico más rápido, y el que la gente deja para el final:
opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.Flag("headless", false),
)
La mitad de las veces la respuesta se ve al instante: un banner de cookies tapando el botón, una redirección a login, una pantalla de desafío, o un layout distinto del que probaste.
Captura la página cuando falla una ejecución. En un entorno headless esto sustituye a mirar:
var buf []byte
_ = chromedp.Run(ctx, chromedp.FullScreenshot(&buf, 90))
_ = os.WriteFile("failure.png", buf, 0644)
Empareja con el HTML, pues una captura muestra lo que se renderizó y el fuente lo que llegó:
var html string
_ = chromedp.Run(ctx, chromedp.OuterHTML("html", &html, chromedp.ByQuery))
Comprueba que el selector resuelve antes de asumir un problema de timing:
var count int
_ = chromedp.Run(ctx, chromedp.Evaluate(`document.querySelectorAll('.item').length`, &count))
Cero significa un problema de selector, y ninguna cantidad de espera lo arregla.
Activa el registro del navegador cuando sospeches que la propia página está fallando:
opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.Flag("enable-logging", true),
chromedp.Flag("v", "1"),
)
Escucha mensajes de consola y peticiones fallidas, que a menudo explican una página que renderiza vacía:
chromedp.ListenTarget(ctx, func(ev interface{}) {
switch e := ev.(type) {
case *runtime.EventConsoleAPICalled:
log.Printf("console.%s", e.Type)
case *network.EventLoadingFailed:
log.Printf("failed: %s %s", e.Type, e.ErrorText)
}
})
Una página cuyas llamadas de API fallan todas parece idéntica a una cuyos selectores cambiaron, y solo los eventos de red las distinguen.
Y usa chromedp-proxy como último recurso. Se sitúa entre tu programa y el navegador y registra el tráfico del DevTools Protocol en ambos sentidos. Cuando el comportamiento no tiene ningún sentido, ver el intercambio real de protocolo suele explicarlo de una lectura.
Cuándo chromedp es la elección correcta
Úsalo cuando ya escribes Go y quieres un único binario estático sin driver que distribuir, o cuando necesitas acceso directo al DevTools Protocol para algo que las herramientas de nivel más alto no exponen.
Considera Playwright cuando quieras soporte entre navegadores, autoespera integrada en cada action, tracing y capturas al fallar, o un cuerpo mayor de documentación y ejemplos. El port a Go existe, pero el ecosistema está centrado en JavaScript y Python.
Considera HTTP simple cuando el contenido está en la respuesta inicial. Más rápido, más barato, más simple, y más de la web sirve HTML útil de lo que el discurso sugiere.
La lista de recursos del propio FAQ es un buen mapa del siguiente paso: el repositorio examples para actions complejas y capturas de página completa, la referencia cdproto para la API de protocolo generada, y chromedp-proxy — un proxy de registro CDP — para ver exactamente qué se dicen tu programa y el navegador, que es la herramienta de depuración de último recurso y una genuinamente buena.
Preguntas frecuentes
¿Qué es chromedp?
Un paquete Go que controla navegadores que hablan el Chrome DevTools Protocol, sin dependencias externas. A diferencia de Selenium no necesita binario de driver, y a diferencia de Playwright no envía runtime: un binario Go más una instalación de Chrome es todo el despliegue.
¿Por qué obtengo "invalid context" en chromedp?
Porque ejecutaste una action sin Run. El FAQ explica que un context de chromedp no tiene executor por defecto. Las actions son valores que chromedp.Run realiza; llamar una directamente no tiene nada que la ejecute.
¿Qué significa "context canceled" en chromedp?
Suele significar que se perdió la conexión con el navegador. El FAQ lo atribuye a que el navegador se cerró a mano o el proceso fue matado. Conviene distinguirlo de un timeout, porque el arreglo es distinto: un Chrome que se estrelló no se resuelve esperando más.
¿Cómo ejecuto chromedp con un navegador visible?
Chrome corre headless por defecto. Añade chromedp.Flag("headless", false) a DefaultExecAllocatorOptions y pásalos a NewExecAllocator, luego deriva tu context de ese allocator.
¿Cómo uso un proxy con chromedp?
Añade chromedp.ProxyServer("http://host:port") a las opciones del allocator. Las credenciales en la URL no funcionan, porque la flag --proxy-server de Chrome no las acepta: usa una lista blanca de IP si la dirección es estable, o maneja la petición de autenticación por el DevTools Protocol.
¿Cómo ejecuto chromedp en Docker?
Ejecuta el programa Go dentro de la imagen chromedp/headless-shell, que el proyecto recomienda de forma explícita. Contiene un build headless más pequeño de Chrome que chromedp encuentra sin configuración, y evita montar un entorno de navegador a mano.
¿Cómo espero un elemento en chromedp?
Usa WaitVisible, WaitReady o WaitNotVisible con un selector, o Poll con una expresión JavaScript para condiciones que ningún selector expresa. Evita Sleep: o es demasiado corto e inestable o demasiado largo y un desperdicio, suele ser ambas cosas según la máquina.
¿Cómo reduzco el ancho de banda al usar chromedp?
Bloquea imágenes, fuentes y medios habilitando la interceptación de peticiones y haciendo fallar esos tipos de recurso, lo que suele quitar la mayor parte del tráfico. Reutiliza un navegador y crea pestañas en vez de asignar una y otra vez. Y comprueba si el contenido está en el HTML inicial, en cuyo caso salta el navegador por completo.
Cierre
La curva de aprendizaje de chromedp es casi por entero el modelo de context. Una vez interiorizas que un context lleva el navegador o la pestaña, que cancelar uno lo cierra, y que las actions no hacen nada hasta que Run las realiza, el resto de la API es directo.
Los hábitos que importan son los mismos que en cualquier automatización de navegador. Espera condiciones en vez de dormir, acota cada espera con un timeout de context, y reutiliza un navegador en muchas pestañas en vez de pagar el arranque una y otra vez.
Dos puntos específicos de Go merecen recordarse. defer cancel() en cada context, o filtras procesos Chrome; y en Linux, chromedp mata a la fuerza los hijos Chrome que arrancó, así que un navegador de larga duración hay que lanzarlo aparte y alcanzarlo con RemoteAllocator.
Y si corres por un proxy medido, bloquea los tipos de recurso que no necesitas antes de cualquier otra cosa. Una página renderizada cuesta un orden de magnitud más que el HTML que contiene, y la mayor parte son imágenes que nunca ibas a mirar.
