You can build a small, polite web scraper in Go with mostly standard-library code, but not entirely. The standard library covers the HTTP client, URL parsing, cancellation, and streaming reads. HTML parsing is the exception: the HTML5 tokenizer and tree parser live in golang.org/x/net/html, an external module you add with go get. The accurate description is therefore “standard library for fetching and URL handling, external module for parsing.”
Which package does what
Each part of a basic scraper maps to one package. Knowing the boundary matters because the external module is versioned separately from Go itself, so it appears in your go.mod file and needs its own upgrade decisions.
As an Amazon Associate I earn from qualifying purchases.
| Task | Package | Practical use in a scraper |
|---|---|---|
| HTTP requests and responses | net/http | Build a reusable http.Client, create requests with a context, read status and headers, and close response bodies. |
| URL parsing, resolution, and encoding | net/url | Validate the start URL, resolve relative links against the page URL, and build query strings without string concatenation. |
| Cancellation and deadlines | context | Stop in-flight requests and the whole crawl when a time budget runs out or the process is interrupted. |
| Streams and bounded reads | io | Read the response body as a stream and cap how many bytes you accept. |
| HTML5 tokenizing and tree parsing | golang.org/x/net/html (external module) | Turn HTML into a document tree or a token stream and extract the elements you need. |
Fetching a page safely
Fetching is where most scrapers go wrong: they run with no timeout, leak connections, or accept whatever arrives. The steps below use only standard-library packages.
Create one reusable client with a timeout
The default helper http.Get uses http.DefaultClient, which sets no timeout. For a scraper, create your own client once and pass it around. The Go Authors’ net/http package documentation (see the net/http source documentation) states: “Clients and Transports are safe for concurrent use by multiple goroutines and for efficiency should only be created once and re-used.”
#1 Best Overall
Concurrency safety of the client is not permission to send unlimited requests to a site. It only means that goroutines can share the client, which is covered in the politeness section below.
A Client.Timeout value covers the whole exchange, including connection setup, redirects, and reading the response body. Set it to a value that suits the target site; 15 seconds is a reasonable starting point for ordinary HTML pages, not a documented default.
Attach a context to every request
Create requests with http.NewRequestWithContext rather than http.NewRequest. Pass in a context from context.WithTimeout or context.WithCancel, so that the crawl as a whole can stop and any in-flight request is cancelled when the context ends. A single context for the whole run is the simplest budget; per-request contexts are useful when some pages should get shorter deadlines.
What’s actually slowing this PC down?
Pick the symptom - the matching free tool is one click away.
Set headers explicitly
Set a User-Agent that identifies your tool and gives the site operator a way to contact you. Use req.Header.Set for this and for any Accept header. Headers belong on the request object, not on the client, when they vary per request.
Check the status, the content type, and close the body
The net/http documentation requires you to close the response body when you are finished with it. Put defer resp.Body.Close() directly after the error check on client.Do, so the body is closed on every path, including error statuses.
- Status: accept only the codes your logic expects. The example below accepts
200 OKand records other statuses as skipped pages. - Content type: check the
Content-Typeheader before parsing. Skip anything that is nottext/html, such as PDFs or images linked from a page. - Redirects: the client follows redirects by default. Use
resp.Request.URLto learn the final URL after redirects so that relative links resolve against the right base.
Bound what you read
Do not assume every remote response is small. Wrap the body in io.LimitReader with one byte more than your limit, then reject the page if it returned more than the limit. The 2 MiB cap in the example is an illustrative choice, not a standard. For error responses you do not need to parse, read only a small bounded amount before closing.
Parsing HTML: tree or token stream
golang.org/x/net/html offers two approaches. Choose by what your extraction needs, not by which one looks more complete.
Quick wins for a faster PC:
Scan for outdated or missing drivers - takes under a minuteDriver Scan →Clear out junk files and repair common Windows errorsFree Scan →Fix the driver behind crashes, sound loss and screen glitchesFind Drivers →html.Parse for a document tree
html.Parse builds a full node tree using HTML5 tree-construction rules. It is convenient when you want to walk the document, find an element, then look at its children and ancestors. The trade-off is memory: the whole document is held as a tree before you can query it.
Rank #3
Two package-level constraints are worth knowing. The package documentation says that malformed input may produce implied, moved, or dropped nodes, so the tree is the parser’s reconstruction rather than a copy of your markup. It also says the input is assumed to be UTF-8 and that nesting deeper than 512 elements is rejected. If a site serves another encoding, decode it to UTF-8 before parsing; the parser does not do that for you.
html.Tokenizer for streaming extraction
html.NewTokenizer reads one token at a time: start tags, end tags, text, comments, and errors. It is a good fit for pulling a title or a few attributes out of a large page without building a tree. Be careful with byte slices. The slices returned by z.Text() and z.Raw() may be overwritten by the next call to z.Next(), so convert them to string immediately if you keep them.
Trust what the parser produces
The package warns that parsing and interpreting untrusted HTML needs care. Do not treat the parsed structure as an exact reproduction of the source nesting, and do not make security or trust decisions from the tree alone. For a scraper that only collects links and text, this is a correctness point, not a reason to avoid the parser.
Free tools Windows power users keep installed
One-click scans. No signup required.
Resolving and filtering links
Extracted links are often relative (/articles/42, ../about). Resolve each one against the URL of the page where you found it, not the start URL. url.URL.ResolveReference does this correctly, and it is the reason to prefer net/url over string concatenation.
After resolution, apply your own policy before adding a link to the queue. The package documentation gives you the URL utilities; the policy is yours. A reasonable minimum is:
- keep only
httpandhttpsschemes; - keep only hosts you intend to crawl, comparing the full host including any port;
- remove fragments so that
/page#aand/page#bare not fetched twice; - skip URLs already seen, using a map keyed by the normalized string.
Choosing between the options
Use this table to decide which approach fits a given job. Neither option is universally better.
| Option | Good for | Trade-off |
|---|---|---|
http.Get (uses http.DefaultClient) |
A one-off fetch in a script | No timeout is set by default, and you cannot set per-request headers through this helper. |
Reusable &http.Client{Timeout: ...} with http.NewRequestWithContext |
A crawler that needs explicit headers, timeouts, redirect rules, and cancellation | More code to write and review, which is the point. |
html.Parse (document tree) |
Queries that need ancestors, siblings, or nested structure | The whole document is held in memory as a tree. |
html.Tokenizer (token stream) |
Extracting titles, links, or attributes from large pages with little memory | You manage the token loop yourself and must handle byte-slice lifetimes. |
Complete example: a bounded, same-host crawler
The program below fetches a start URL, follows same-host links breadth-first, prints each page’s final URL and title, and stops after 20 pages. It is sequential and waits one second between requests. It uses only net/http, net/url, context, and io from the standard library, plus golang.org/x/net/html.
Do these 3 things before closing this tab:
1Repair Windows errors before they cause bigger problems2Scan for outdated or missing drivers - takes under a minute3Clear out junk files and repair common Windows errorsSet up the module
- Create a directory and enter it:
mkdir scraper && cd scraper - Initialize the module:
go mod init scraper - Add the HTML module:
go get golang.org/x/net/html. Checkgo.modafterwards and keep the version your project pins. - Save the code below as
main.go. - Run it against a site you are permitted to crawl:
go run . https://example.com/
Each output line is a tab-separated final URL and page title. The program prints nothing for pages it skips, and it reports skipped pages on standard error.
Best Value
main.go
package main
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
"golang.org/x/net/html"
)
const (
maxBodyBytes = 2 << 20 // 2 MiB cap per page (illustrative)
maxPages = 20
crawlDelay = 1 * time.Second
userAgent = "example-scraper/1.0"
)
// fetchPage GETs a URL and returns the final URL after redirects and the body.
func fetchPage(ctx context.Context, client *http.Client, raw string) (*url.URL, []byte, error) {
target, err := url.Parse(raw)
if err != nil {
return nil, nil, err
}
if target.Scheme != "http" && target.Scheme != "https" {
return nil, nil, fmt.Errorf("unsupported scheme in %q", raw)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil)
if err != nil {
return nil, nil, err
}
req.Header.Set("User-Agent", userAgent)
req.Header.Set("Accept", "text/html")
resp, err := client.Do(req)
if err != nil {
return nil, nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
return nil, nil, fmt.Errorf("GET %s: %s", target, resp.Status)
}
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
return nil, nil, fmt.Errorf("GET %s: unexpected Content-Type %q", target, ct)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes+1))
if err != nil {
return nil, nil, err
}
if len(body) > maxBodyBytes {
return nil, nil, fmt.Errorf("GET %s: body larger than %d bytes", target, maxBodyBytes)
}
return resp.Request.URL, body, nil
}
// extractLinks returns absolute http(s) links from anchor tags, fragments removed.
func extractLinks(page *url.URL, body []byte) ([]string, error) {
doc, err := html.Parse(bytes.NewReader(body))
if err != nil {
return nil, err
}
var links []string
var walk func(*html.Node)
walk = func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "a" {
for _, a := range n.Attr {
if a.Key != "href" {
continue
}
ref, err := url.Parse(strings.TrimSpace(a.Val))
if err != nil {
break
}
abs := page.ResolveReference(ref)
if abs.Scheme == "http" || abs.Scheme == "https" {
abs.Fragment = ""
links = append(links, abs.String())
}
break
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(doc)
return links, nil
}
// firstTitle streams tokens until it finds the text.
func firstTitle(body []byte) string {
z := html.NewTokenizer(bytes.NewReader(body))
for {
switch z.Next() {
case html.ErrorToken:
return ""
case html.StartTagToken:
if z.Token().Data == "title" && z.Next() == html.TextToken {
return strings.TrimSpace(string(z.Text()))
}
}
}
}
func main() {
if len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "usage: scraper <start-url>")
os.Exit(2)
}
start, err := url.Parse(os.Args[1])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
client := &http.Client{Timeout: 15 * time.Second}
queue := []string{start.String()}
seen := map[string]bool{start.String(): true}
for visited := 0; len(queue) > 0 && visited < maxPages; visited++ {
next := queue[0]
queue = queue[1:]
page, body, err := fetchPage(ctx, client, next)
if err != nil {
fmt.Fprintln(os.Stderr, "skip:", err)
} else {
links, err := extractLinks(page, body)
if err != nil {
fmt.Fprintln(os.Stderr, "parse:", err)
}
for _, l := range links {
u, _ := url.Parse(l)
if u.Host != start.Host || seen[l] {
continue
}
seen[l] = true
queue = append(queue, l)
}
fmt.Printf("%st%sn", page, firstTitle(body))
}
select {
case <-ctx.Done():
fmt.Fprintln(os.Stderr, "stopped:", ctx.Err())
return
case <-time.After(crawlDelay):
}
}
}
What the example leaves out on purpose
The program does not read robots.txt, does not retry failed requests, does not cache pages, and does not persist its queue. Each of these is a real requirement for a production crawler. Add them before pointing the program at a site beyond a short test run.
Politeness and crawl policy
Control rate and concurrency
The example waits one second between requests and runs one request at a time. If you add goroutines, bound them with a fixed worker count or a semaphore channel, and apply a per-host limit so that one slow host does not consume all your workers. Share the single http.Client across workers, as described earlier. Cancel all workers through the same context so a stop signal ends the crawl cleanly.
Treat robots.txt as a signal, not permission
RFC 9309 defines the Robots Exclusion Protocol, the standard way for a site to tell crawlers which paths it asks them to avoid. Read the file at /robots.txt on each host and honor its rules for your user agent. A robots.txt file coordinates crawler behavior; it is not authentication, authorization, or a security control, and it does not replace a site’s terms of use or the legal requirements that apply to your activity. Check those for each site yourself.
Watch redirects that carry credentials
The client follows redirects by default. If your requests include an Authorization header, be aware that Go’s security decisions describe removing that header when a redirect goes to a domain that is neither an exact match nor a subdomain of the original. For a scraper with a defined scope, http.Client.CheckRedirect lets you refuse redirects that leave your allowed hosts. The Go security decisions document explains the header behavior.
Quick Recap
Limits of a standard-library-first scraper
- No JavaScript execution. An HTTP client plus an HTML parser reads the HTML the server returns. If the content appears only after client-side rendering, this approach will not see it. Browser automation is a separate tool with different costs.
- Encoding. The parser assumes UTF-8. Decode other encodings first.
- Malformed markup. The parser repairs broken HTML under HTML5 rules, so extracted values can reflect that reconstruction. Test against real pages from your target.
- Deep nesting. Documents nested more than 512 elements deep are rejected by the parser, so handle that error rather than assuming every page parses.
- Dependency upkeep. The HTML module is versioned separately from the Go standard library, so review its version as part of routine dependency updates.
Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.




