Skip to main content

Confirm

Are you sure?

Go · Code recipe

Run a Lighthouse audit in Go

Score any page for performance, accessibility, best practices and SEO, in Go 1.21+ with net/http from the standard library. Every program on this page runs as it stands — each one was run against a stub of the API before it was published.

By · Last updated: September 2026

TL;DR

To run a Lighthouse audit in Go, POST a URL and a device to https://urlpipe.dev/lighthouse and read the JSON with json.Unmarshal into a struct: four category scores from 0 to 1 and the lab metrics, LCP, CLS and TBT among them. An audit takes around 15 seconds, so the async variant with a webhook suits production. It costs 2 credits.

Free plan, no credit card. 1,000 credits a month.

One POST to /lighthouse runs a real Lighthouse audit in Chrome and answers with the four category scores — performance, accessibility, best practices, SEO — and the lab metrics behind the performance score. device picks mobile (the default, throttled CPU and network) or desktop.

In Go the report is read with json.Unmarshal into a struct. Scores run from 0 to 1, so the program multiplies by 100 to print what the Lighthouse report shows; metrics carry a ready-formatted displayValue. The guide to reading a Lighthouse audit explains what each number means.

Setup

Before you start

Nothing to fetch: every import is standard library, so each program runs with go run as a single file. Put your API key in the environment.

Terminal
go version   # go1.21 or later
export URLPIPE_API_KEY="your_api_key"

The request

Print the four scores, LCP, CLS and TBT

"sync": true keeps the request open until the result is ready. http.Client has no timeout unless you set one, and res.Body is a stream: io.ReadAll turns it into bytes, and defer res.Body.Close() hands the connection back. Map values are pointers so a category or metric that came back null is nil, not a zero score.

main.go
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"math"
	"net/http"
	"os"
	"time"
)

func main() {
	payload, err := json.Marshal(map[string]any{
		"url":    "https://example.com",
		"device": "mobile",
		"sync":   true,
	})
	if err != nil {
		log.Fatal(err)
	}

	req, err := http.NewRequest(http.MethodPost, "https://urlpipe.dev/lighthouse", bytes.NewReader(payload))
	if err != nil {
		log.Fatal(err)
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv("URLPIPE_API_KEY"))
	req.Header.Set("Content-Type", "application/json")

	// A sync call can take up to 60 s; the zero-value client would wait forever.
	client := &http.Client{Timeout: 90 * time.Second}
	res, err := client.Do(req)
	if err != nil {
		log.Fatal(err)
	}
	defer res.Body.Close()

	// The body is the result or the error, so read all of it first.
	body, err := io.ReadAll(res.Body)
	if err != nil {
		log.Fatal(err)
	}
	if res.StatusCode != http.StatusOK {
		log.Fatalf("URLpipe answered %d: %s", res.StatusCode, body)
	}

	var report struct {
		Categories map[string]*struct {
			Score *float64 `json:"score"`
		} `json:"categories"`
		Metrics map[string]*struct {
			DisplayValue string `json:"displayValue"`
		} `json:"metrics"`
	}
	if err := json.Unmarshal(body, &report); err != nil {
		log.Fatal(err)
	}

	for _, name := range []string{"performance", "accessibility", "best-practices", "seo"} {
		score := "n/a"
		if category := report.Categories[name]; category != nil && category.Score != nil {
			score = fmt.Sprint(math.Round(*category.Score * 100))
		}
		fmt.Printf("%s: %s\n", name, score)
	}

	for _, metric := range []struct{ label, key string }{
		{"LCP", "largest-contentful-paint"},
		{"CLS", "cumulative-layout-shift"},
		{"TBT", "total-blocking-time"},
	} {
		value := "n/a"
		if m := report.Metrics[metric.key]; m != nil {
			value = m.DisplayValue
		}
		fmt.Printf("%s: %s\n", metric.label, value)
	}
}

Run it: go run main.go

Given the example response on the docs page, it prints:

Output
performance: 95
accessibility: 88
best-practices: 92
seo: 90
LCP: 2.5 s
CLS: 0.05
TBT: 150 ms

Async

The async variant: a token, a webhook and a poll

Leave out sync and the answer is a token, straight away. Decode only the field you need into an anonymous struct; switch on the status covers the four answers GET /result/:token can give.

main.go
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"math"
	"net/http"
	"os"
	"time"
)

const api = "https://urlpipe.dev"

var client = &http.Client{Timeout: 30 * time.Second}

// send makes one request to the API and returns the status and the body.
func send(method, path string, payload any) (int, []byte) {
	var reader io.Reader
	if payload != nil {
		data, err := json.Marshal(payload)
		if err != nil {
			log.Fatal(err)
		}
		reader = bytes.NewReader(data)
	}
	req, err := http.NewRequest(method, api+path, reader)
	if err != nil {
		log.Fatal(err)
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv("URLPIPE_API_KEY"))
	req.Header.Set("Content-Type", "application/json")

	res, err := client.Do(req)
	if err != nil {
		log.Fatal(err)
	}
	defer res.Body.Close()
	body, err := io.ReadAll(res.Body)
	if err != nil {
		log.Fatal(err)
	}
	return res.StatusCode, body
}

// poll collects a result by token. The result is POSTed to report_to when it is
// ready; polling is the other way to collect it, and a backup for the webhook.
func poll(token string) []byte {
	for attempt := 0; attempt < 60; attempt++ {
		status, body := send(http.MethodGet, "/result/"+token, nil)
		switch status {
		case http.StatusOK:
			return body
		case http.StatusAccepted: // still processing
			time.Sleep(2 * time.Second)
		case http.StatusUnprocessableEntity:
			log.Fatalf("The analysis failed: %s", body)
		case http.StatusGone:
			log.Fatal("The result is past the 30-day window; send the request again.")
		default:
			log.Fatalf("URLpipe answered %d: %s", status, body)
		}
	}
	log.Fatal("Still processing after two minutes; try the token again later.")
	return nil
}

func main() {
	// No "sync": the request is accepted at once and the work carries on without you.
	status, body := send(http.MethodPost, "/lighthouse", map[string]any{
		"url":       "https://example.com",
		"device":    "mobile",
		"report_to": "https://your-app.com/webhooks/urlpipe",
		"labels":    map[string]any{"customer": "acme"},
	})
	if status != http.StatusOK {
		log.Fatalf("URLpipe answered %d: %s", status, body)
	}
	var accepted struct {
		Token string `json:"token"`
	}
	if err := json.Unmarshal(body, &accepted); err != nil {
		log.Fatal(err)
	}
	fmt.Println("Accepted", accepted.Token)

	body = poll(accepted.Token)
	var report struct {
		Categories map[string]*struct {
			Score *float64 `json:"score"`
		} `json:"categories"`
		Metrics map[string]*struct {
			DisplayValue string `json:"displayValue"`
		} `json:"metrics"`
	}
	if err := json.Unmarshal(body, &report); err != nil {
		log.Fatal(err)
	}

	for _, name := range []string{"performance", "accessibility", "best-practices", "seo"} {
		score := "n/a"
		if category := report.Categories[name]; category != nil && category.Score != nil {
			score = fmt.Sprint(math.Round(*category.Score * 100))
		}
		fmt.Printf("%s: %s\n", name, score)
	}

	for _, metric := range []struct{ label, key string }{
		{"LCP", "largest-contentful-paint"},
		{"CLS", "cumulative-layout-shift"},
		{"TBT", "total-blocking-time"},
	} {
		value := "n/a"
		if m := report.Metrics[metric.key]; m != nil {
			value = m.DisplayValue
		}
		fmt.Printf("%s: %s\n", metric.label, value)
	}
}

Run it: go run main.go

Errors

Handle errors and retries

Errors are values: urlpipe() returns one for every refusal it will not retry, and main decides what to do with it. A 401 answers in plain text, so it is handled before the JSON decode; max is a builtin from Go 1.21.

main.go
package main

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log"
	"math"
	"net/http"
	"os"
	"strconv"
	"time"
)

var client = &http.Client{Timeout: 90 * time.Second}

// urlpipe POSTs a sync request and returns the result body. It retries the two
// 429s that clear by themselves and returns an error for everything else.
func urlpipe(path string, payload map[string]any) ([]byte, error) {
	payload["sync"] = true
	data, err := json.Marshal(payload)
	if err != nil {
		return nil, err
	}

	const attempts = 5
	for attempt := 0; attempt < attempts; attempt++ {
		req, err := http.NewRequest(http.MethodPost, "https://urlpipe.dev"+path, bytes.NewReader(data))
		if err != nil {
			return nil, err
		}
		req.Header.Set("Authorization", "Bearer "+os.Getenv("URLPIPE_API_KEY"))
		req.Header.Set("Content-Type", "application/json")

		res, err := client.Do(req)
		if err != nil {
			return nil, err
		}
		body, err := io.ReadAll(res.Body)
		res.Body.Close()
		if err != nil {
			return nil, err
		}

		switch res.StatusCode {
		case http.StatusOK:
			return body, nil
		case http.StatusUnauthorized:
			return nil, errors.New("401: the API key is missing or wrong, check URLPIPE_API_KEY")
		}

		var e struct {
			Code    string `json:"error"`
			Message string `json:"message"`
			Token   string `json:"token"`
		}
		_ = json.Unmarshal(body, &e) // a body that is not JSON leaves e empty
		detail := e.Code
		if e.Message != "" {
			detail = e.Code + ": " + e.Message
		}

		switch {
		case res.StatusCode == http.StatusTooManyRequests && e.Code == "rate_limited":
			// Sending too fast: Retry-After says how long the window has left.
			seconds, _ := strconv.Atoi(res.Header.Get("Retry-After"))
			time.Sleep(time.Duration(max(seconds, 1)) * time.Second)
		case res.StatusCode == http.StatusTooManyRequests && e.Code == "concurrency_limit":
			// Every parallel slot on your plan is busy with your own requests.
			time.Sleep(time.Duration(1<<attempt) * time.Second)
		case res.StatusCode == http.StatusGatewayTimeout:
			// Still running on our side; the token collects it from GET /result/:token.
			return nil, fmt.Errorf("504 processing_timeout: collect it later with token %s", e.Token)
		default:
			// 403 email_unverified, 422 (a bad parameter, or a page that would not load),
			// 429 quota_exceeded: sending the same request again gets the same answer.
			return nil, fmt.Errorf("%d: %s", res.StatusCode, detail)
		}
	}
	return nil, fmt.Errorf("429: still refused after %d attempts", attempts)
}

func main() {
	body, err := urlpipe("/lighthouse", map[string]any{
		"url":    "https://example.com",
		"device": "mobile",
	})
	if err != nil {
		log.Fatalf("URLpipe: %v", err)
	}

	var report struct {
		Categories map[string]*struct {
			Score *float64 `json:"score"`
		} `json:"categories"`
		Metrics map[string]*struct {
			DisplayValue string `json:"displayValue"`
		} `json:"metrics"`
	}
	if err := json.Unmarshal(body, &report); err != nil {
		log.Fatal(err)
	}

	for _, name := range []string{"performance", "accessibility", "best-practices", "seo"} {
		score := "n/a"
		if category := report.Categories[name]; category != nil && category.Score != nil {
			score = fmt.Sprint(math.Round(*category.Score * 100))
		}
		fmt.Printf("%s: %s\n", name, score)
	}

	for _, metric := range []struct{ label, key string }{
		{"LCP", "largest-contentful-paint"},
		{"CLS", "cumulative-layout-shift"},
		{"TBT", "total-blocking-time"},
	} {
		value := "n/a"
		if m := report.Metrics[metric.key]; m != nil {
			value = m.DisplayValue
		}
		fmt.Printf("%s: %s\n", metric.label, value)
	}
}

Run it: go run main.go

Details

What to know about /lighthouse

  • Lighthouse 13 reports four categories; the pwa key is always null, kept so the shape does not change.
  • The performance score weights TBT 30%, LCP 25%, CLS 25%, FCP 10% and Speed Index 10%.
  • INP needs real users and cannot be measured in a lab run; TBT is its lab stand-in.
  • "include_audits": "true" adds the full list of audits, with the elements to fix. Mobile and desktop are cached separately.
  • Want the audit run on a schedule, with a history and alerts? Full Stack Audit sells that as a monitored report; URLpipe is the primitive underneath.

Other languages

Run a Lighthouse audit in another language

More Go: every Go recipe

FAQ

Frequently asked questions

Mobile or desktop — which device should I audit?
Mobile, unless you know your traffic is desktop. It emulates a 360×640 phone with a 4× slower CPU and a slow 4G network, which gives the lower, more telling scores. Pass device: "desktop" for a 1350×940 window with no throttling.
Why isn't INP in the results?
INP is measured from real users interacting with the page, which a lab audit cannot do. Total Blocking Time is the lab metric that tracks it.
Why use the async variant for Lighthouse?
An audit takes around 15 seconds, longer on a heavy page. Async hands you a token at once and delivers the report to your webhook, so nothing holds a connection open waiting.
Do I need an SDK to call URLpipe from Go?
No module to fetch: net/http and encoding/json are standard library, and the API is one POST per job.

Get a key and run it.

Free plan, no card. Paste your key into URLPIPE_API_KEY and every program on this page runs as it is.