Go Web Scraping Guide: Colly Crawlers, goquery Parsing and chromedp Rendering Behind Proxies

Go suits data collection unusually well: one statically linked executable can keep thousands of requests in flight while using modest memory. Gophers typically combine three tools. The standard net/http package plus goquery downloads and dissects server-rendered markup, Colly orchestrates whole-site crawls through event handlers, rate rules and link following, and chromedp remote-controls Chrome for pages that reveal their content only after scripts execute. Each of them can be pointed at a proxy, and changing the exit address between requests needs just a handful of lines.
A typical tutorial shows one Colly snippet and calls it a day. This one implements the same tiny book-catalogue collector in three styles, wires an authenticated gateway into every layer, spreads traffic across several exits, keeps parallelism courteous and writes the output to CSV and JSON. It finishes with a lookup table of common failures.
Rotating proxies for Go scrapers
One gateway for net/http, Colly and chromedp with a new IP per request or sticky sessions
Which Go Package Does What
| Package | Job in the pipeline | Runs page scripts | Gateway configuration | Reach for it when |
|---|---|---|---|---|
net/http from the stdlib | Low-level request client | No | Transport.Proxy accepting HTTP or SOCKS5 URLs | You want total control or call JSON endpoints with zero dependencies |
| goquery | Selector engine modelled on jQuery, built over x/net/html | No | None needed, parsing only | Pulling values out of markup you already hold |
| Colly v2 | Crawl engine with handlers, throttling, caching and async mode | No | SetProxy, SetProxyFunc, a built-in round-robin helper | Paginated catalogues and sites you traverse via links |
| chromedp | Chrome automation over the DevTools Protocol | Yes | The ProxyServer option of the allocator | Client-rendered views, clicking, infinite scroll |
Sensible order of escalation: let Colly handle site traversal, drop down to net/http with goquery whenever a request needs hand tuning, and keep chromedp in reserve for views whose initial HTML arrives empty. Newcomers can first read our primer on web scraping, which walks through the download, extract and persist loop shared by every language.
Setting Up the Project
Grab a recent Go toolchain and initialise a module:
mkdir shelfscout && cd shelfscout
go mod init example.com/shelfscout
go get github.com/gocolly/colly/v2 github.com/PuerkitoBio/goquery github.com/chromedp/chromedpEvery example hits books.toscrape.com, a fake bookstore published specifically so developers can rehearse crawling on it. Gateway details live in environment variables: EXIT_URL contains the complete http://user:pass@host:port string, and EXIT_HOSTPORT stores just host:port for Chrome.
Step 1. net/http and goquery
In the standard library the gateway is attached to the transport. Hand http.ProxyURL a parsed address and any username and password inside it are forwarded to the gateway for you.
package main
import (
"fmt"
"log"
"net/http"
"net/url"
"os"
"time"
"github.com/PuerkitoBio/goquery"
)
func newFetcher() *http.Client {
exit, err := url.Parse(os.Getenv("EXIT_URL"))
if err != nil {
log.Fatal(err)
}
return &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
Proxy: http.ProxyURL(exit),
MaxIdleConnsPerHost: 4,
},
}
}
func main() {
fetcher := newFetcher()
req, _ := http.NewRequest("GET", "https://books.toscrape.com/", nil)
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36")
req.Header.Set("Accept-Language", "en-US,en;q=0.8")
resp, err := fetcher.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
log.Fatal(err)
}
doc.Find("article.product_pod").Each(func(_ int, card *goquery.Selection) {
title, _ := card.Find("h3 a").Attr("title")
price := card.Find("p.price_color").Text()
fmt.Printf("%s | %s\n", title, price)
})
}Production code should inspect resp.StatusCode first, since goquery will parse an error page just as readily as a real one. The scheme of the gateway address picks the protocol, which means a socks5:// value works with the same transport. If you are not sure which flavour to buy, our breakdown of SOCKS5 and HTTP proxies lays out the differences, and the explainer on HTTP vs HTTPS proxies describes how TLS traffic travels through a CONNECT tunnel.
Step 2. Crawling a Catalogue in Colly
Colly hides the fetch loop behind event handlers. Each element matched by an OnHTML selector triggers your function, a Visit call inside a handler queues the linked page, and a LimitRule caps parallel requests and adds jitter for a given domain.
package main
import (
"fmt"
"log"
"os"
"time"
"github.com/gocolly/colly/v2"
)
type Book struct {
Title string `json:"title"`
Price string `json:"price"`
Link string `json:"link"`
}
func main() {
var shelf []Book
crawler := colly.NewCollector(
colly.AllowedDomains("books.toscrape.com"),
colly.UserAgent("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"),
)
crawler.SetRequestTimeout(30 * time.Second)
if err := crawler.SetProxy(os.Getenv("EXIT_URL")); err != nil {
log.Fatal(err)
}
crawler.Limit(&colly.LimitRule{
DomainGlob: "*",
Parallelism: 2,
RandomDelay: 2 * time.Second,
})
crawler.OnHTML("article.product_pod", func(card *colly.HTMLElement) {
shelf = append(shelf, Book{
Title: card.ChildAttr("h3 a", "title"),
Price: card.ChildText("p.price_color"),
Link: card.Request.AbsoluteURL(card.ChildAttr("h3 a", "href")),
})
})
crawler.OnHTML("li.next a", func(next *colly.HTMLElement) {
next.Request.Visit(next.Attr("href"))
})
crawler.OnError(func(r *colly.Response, err error) {
log.Printf("failed %s: %d %v", r.Request.URL, r.StatusCode, err)
})
crawler.Visit("https://books.toscrape.com/catalogue/page-1.html")
fmt.Println("books collected:", len(shelf))
}Because this collector is synchronous, appending to the slice needs no locking. Switch on colly.Async(true) for throughput and you must guard shared state with a mutex and call crawler.Wait() before touching the results.

Step 3. Spreading Requests Across Several Exits
For multiple gateways, the proxy subpackage of Colly offers a helper that cycles through the addresses you give it, one per request:
import "github.com/gocolly/colly/v2/proxy"
switcher, err := proxy.RoundRobinProxySwitcher(
os.Getenv("EXIT_URL_A"),
os.Getenv("EXIT_URL_B"),
os.Getenv("EXIT_URL_C"),
)
if err != nil {
log.Fatal(err)
}
crawler.SetProxyFunc(switcher)In bare net/http the Proxy field of the transport is itself a callback, so selecting an exit per request is up to you, for instance by stepping through a slice with an atomic counter. Many teams avoid client-side rotation altogether: a rotating gateway hands every new connection a fresh address, or pins one address for a sticky session, depending on the username or port. Aim all clients at one rotating proxies endpoint and the swapping happens on the provider side. Our piece on IP rotation explains when to rotate per request and when per session.
A subtlety: Go keeps connections alive and reuses them, so a burst of requests may share one tunnel and therefore one exit address. When each request must leave from a different IP, close idle connections between calls or turn keep-alives off in the transport.
Step 4. Rendering Script-Driven Pages in chromedp
When values appear on screen but not in the fetched HTML, client-side code is injecting them. Inspect the Fetch/XHR entries in DevTools first: replaying a JSON call through net/http costs a fraction of a browser session. Only if nothing like that exists should you start Chrome through chromedp.
The snippet follows the long-standing chromedp style seen in most guides, where Run executes a list of actions and fills variables you pass by pointer. According to the project README, releases from v0.20 onward switched to a generic, iterator-based interface and come with a migration document, so pin an older release in go.mod or translate these calls using that document.
package main
import (
"context"
"fmt"
"log"
"os"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"github.com/chromedp/chromedp"
)
func main() {
flags := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.ProxyServer("http://"+os.Getenv("EXIT_HOSTPORT")),
chromedp.Flag("blink-settings", "imagesEnabled=false"),
)
allocCtx, stopAlloc := chromedp.NewExecAllocator(context.Background(), flags...)
defer stopAlloc()
tabCtx, stopTab := chromedp.NewContext(allocCtx)
defer stopTab()
tabCtx, stopTimer := context.WithTimeout(tabCtx, 45*time.Second)
defer stopTimer()
var markup string
err := chromedp.Run(tabCtx,
chromedp.Navigate("https://quotes.toscrape.com/js/"),
chromedp.WaitVisible("div.quote", chromedp.ByQuery),
chromedp.OuterHTML("body", &markup, chromedp.ByQuery),
)
if err != nil {
log.Fatal(err)
}
page, _ := goquery.NewDocumentFromReader(strings.NewReader(markup))
page.Find("div.quote span.text").Each(func(_ int, s *goquery.Selection) {
fmt.Println(s.Text())
})
}Chrome discards any username and password embedded in its proxy flag, hence EXIT_HOSTPORT carries only the host and port. Two easy workarounds are adding your machine IP to the provider allowlist or running a local relay without a password that forwards to the authenticated gateway. Responding to the authentication challenge via the DevTools Fetch domain also works in principle, but the exact code varies between chromedp versions, so verify it on yours.
Step 5. Polite Parallelism
Spawning goroutines costs next to nothing, which means the brake has to be yours. A buffered channel doubles as a semaphore and caps how many requests hit one site at once:
gate := make(chan struct{}, 4)
var wg sync.WaitGroup
for _, target := range pages {
wg.Add(1)
go func(addr string) {
defer wg.Done()
gate <- struct{}{}
defer func() { <-gate }()
grab(addr)
time.Sleep(time.Duration(800+rand.Intn(1200)) * time.Millisecond)
}(target)
}
wg.Wait()Four simultaneous requests with jitter make a reasonable opening setting for a modest site. Raise the number only while latency and status codes stay healthy, and when 429 replies appear, pause for the interval named in Retry-After. Pacing, headers and the choice of IP type are covered in more depth in our guide to proxies in web scraping.
Step 6. Writing CSV and JSON Files
func saveShelf(shelf []Book) error {
sheet, err := os.Create("books.csv")
if err != nil {
return err
}
defer sheet.Close()
w := csv.NewWriter(sheet)
w.Write([]string{"title", "price", "link"})
for _, b := range shelf {
w.Write([]string{b.Title, b.Price, b.Link})
}
w.Flush()
if err := w.Error(); err != nil {
return err
}
blob, err := json.MarshalIndent(shelf, "", " ")
if err != nil {
return err
}
return os.WriteFile("books.json", blob, 0o644)
}Field names in the JSON output come from the struct tags on Book. Since csv.Writer holds data in a buffer, calling Flush and then checking Error is mandatory.

Troubleshooting Table
| Message or symptom | What usually causes it | Remedy |
|---|---|---|
407 Proxy Authentication Required | Typo in the credentials, unescaped symbols in the password | Build the address with url.UserPassword, review the IP allowlist |
proxyconnect tcp: ... connection refused | Gateway host or port is wrong or down | Double-check host:port, test the gateway with curl |
Forbidden domain reported by Colly | Target lies outside AllowedDomains | Extend the list or inspect where the redirect leads |
context deadline exceeded from chromedp | The awaited selector never rendered, sluggish gateway | Verify the selector, lengthen the timeout |
| 403 status or an interstitial check | Address already flagged, stock Go headers | Realistic headers, residential exits, a slower cadence |
| Every request shows one IP | A kept-alive tunnel is being reused | Disable keep-alives or select per-request rotation on the gateway |
| Nothing extracted | Script-rendered content or a cookie consent screen | Dump the HTML to disk, hunt for an XHR source, fall back to chromedp |
Restrict collection to public pages, review robots.txt and the terms of each site beforehand, and leave out personal data you have no use for.
Go web scraping FAQ
Is Go a good choice for scraping?
It is, particularly for big crawls. Goroutines keep massive parallelism affordable, a single binary deploys anywhere, and the trio of Colly, goquery and chromedp handles traversal, extraction and rendering, even if the Python ecosystem has more plug-ins.
How do Colly and goquery relate?
goquery works on markup that is already in memory and answers CSS selector queries against it. Colly sits a level higher: it fetches pages, follows links, enforces throttling and dispatches your handlers, relying on goquery under the hood for selector matching.
Where does the proxy go in a Colly collector?
Use SetProxy with one complete gateway address when a single exit is enough. For several exits, build a switcher with RoundRobinProxySwitcher from colly/v2/proxy and hand it to SetProxyFunc; credentials sit inside each address, as in http://user:pass@host:port.
What about sites that depend on JavaScript?
chromedp covers them by steering a genuine Chrome or Chromium through the DevTools Protocol. Still, check for a background JSON request first, because calling it directly is many times quicker than rendering the page.
How much parallelism is safe for one site?
Open with two to four simultaneous requests per site plus randomised pauses, then increase gradually as long as replies stay fast and free of 429 or 403 codes. The ceiling is dictated by the target server, not by the language.


