Go Screenshot API: rs-go SDK, Context and errgroup

Take website screenshots from Go: the rs-go SDK, context-aware net/http calls, errgroup fan-out with Retry-After backoff, a streaming handler, httptest.

By Simon Chiu · Updated

This page shows how to take website screenshots from Go with RenderScreenshot. It starts with the official rs-go SDK, then moves to plain net/http for the two things Go programs care about most: cancellation through context.Context and bounded concurrency with errgroup. It ends with a streaming handler and httptest tests. Other languages are on the screenshot API code examples hub.

Last tested: 2026-10-18 with Go 1.27.1, github.com/Render-Screenshot/rs-go v1.1.1 and golang.org/x/sync v0.24.0. go vet and gofmt were clean. The programs ran against a local stub of our API that returns the documented image, batch and error responses, including a 429 with Retry-After; the tests below passed. We did not call the live API for this page.

Install

go get github.com/Render-Screenshot/[email protected]

The SDK needs Go 1.21 and uses only the standard library. The examples on this page need Go 1.22 or later, because they use r.PathValue and per-iteration loop variables. Set the key in the environment:

export RS_API_KEY=rs_live_xxxxx

Quick start with the SDK

package main

import (
    "context"
    "log"
    "os"
    "time"

    rs "github.com/Render-Screenshot/rs-go"
)

func main() {
    client, err := rs.New(os.Getenv("RS_API_KEY"), rs.WithTimeout(60*time.Second))
    if err != nil {
        log.Fatal(err)
    }
    img, err := client.Take(context.Background(),
        rs.URL("https://example.com").Preset("og_card").BlockCookieBanners())
    if err != nil {
        log.Fatal(err)
    }
    if err := os.WriteFile("example-og.png", img, 0o644); err != nil {
        log.Fatal(err)
    }
}

Take returns the image as []byte. rs.URL(...) is a mutable builder; every setter returns the same pointer. The Go SDK reference lists all setters, and the presets reference shows what og_card sets.

Two things we found in the SDK

We ran rs-go v1.1.1 against our stub and read its source before writing this page. Two behaviours shape the rest of the code here.

The context is accepted but not used. Take(ctx, ...) names its context parameter _. A call with an already-cancelled context still completed and returned the image. The request is bounded only by WithTimeout. If you need a render to stop when an HTTP client disconnects or a sibling goroutine fails, send the request yourself with http.NewRequestWithContext.

IsRetryable() is false on a 429. The SDK stores the API's error code as is, and the API reports a rate limit as too_many_requests. That code is not in the SDK's retryable list, so WithMaxRetries(2) gave up on the first 429. This is what errors.As returned for three stub responses:

Stub response HTTPStatus Code IsRetryable() rs.IsRateLimited(err) RetryAfter
429 with Retry-After: 1 429 too_many_requests false true 1
422 target timeout 422 timeout true false 0
400 bad URL 400 invalid_url false false 0

rs.IsRateLimited checks the status code, so it is the helper to use. 5xx responses are retryable in the SDK. Every API code has its own page under the error codes index.

A context-aware call with net/http

This function is the raw-HTTP fallback, and the rest of the page builds on it. It returns the open response so callers can stream the body:

package main

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

var apiBase = "https://api.renderscreenshot.com"

// APIError is the error envelope the API returns on a non-2xx status.
type APIError struct {
    Status     int
    Code       string `json:"code"`
    Message    string `json:"message"`
    Docs       string `json:"docs"`
    RetryAfter time.Duration
}

func (e *APIError) Error() string { return fmt.Sprintf("%d %s: %s", e.Status, e.Code, e.Message) }

// shoot starts a render and returns the open response. The caller closes the body.
// ctx cancels the request, including a render in progress.
func shoot(ctx context.Context, payload map[string]any) (*http.Response, error) {
    body, err := json.Marshal(payload)
    if err != nil {
        return nil, err
    }
    req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiBase+"/v1/screenshot", bytes.NewReader(body))
    if err != nil {
        return nil, err
    }
    req.Header.Set("Authorization", "Bearer "+os.Getenv("RS_API_KEY"))
    req.Header.Set("Content-Type", "application/json")

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, err
    }
    if resp.StatusCode == http.StatusOK {
        return resp, nil
    }
    defer resp.Body.Close()
    var env struct{ Error APIError }
    _ = json.NewDecoder(io.LimitReader(resp.Body, 1<<16)).Decode(&env)
    env.Error.Status = resp.StatusCode
    if s, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
        env.Error.RetryAfter = time.Duration(s) * time.Second
    }
    return nil, &env.Error
}

The payload is the nested JSON of POST /v1/screenshot. The error's docs field links to the page for its code.

Fan out with errgroup and back off on 429

errgroup.SetLimit caps the number of renders in flight, and WithContext cancels the renders still running when one fails for good. Rate limits wait for Retry-After, but they also watch the context, so a cancelled group does not sleep on:

// captureAll renders every URL into dir, at most `limit` at a time.
// The first hard failure cancels the renders still running.
func captureAll(ctx context.Context, urls []string, dir string, limit int) error {
    g, ctx := errgroup.WithContext(ctx)
    g.SetLimit(limit)
    for i, u := range urls {
        g.Go(func() error {
            path := filepath.Join(dir, strconv.Itoa(i)+".png")
            return captureToFile(ctx, u, path)
        })
    }
    return g.Wait()
}

func captureToFile(ctx context.Context, url, path string) error {
    for attempt := 1; ; attempt++ {
        resp, err := shoot(ctx, map[string]any{"url": url, "preset": "og_card"})
        var apiErr *APIError
        if errors.As(err, &apiErr) && (apiErr.Status == 429 || apiErr.Status >= 500) && attempt < 4 {
            wait := apiErr.RetryAfter
            if wait == 0 {
                wait = time.Duration(1<<attempt) * time.Second
            }
            select {
            case <-time.After(wait):
                continue
            case <-ctx.Done():
                return ctx.Err()
            }
        }
        if err != nil {
            return err
        }
        f, err := os.Create(path)
        if err != nil {
            resp.Body.Close()
            return err
        }
        _, err = io.Copy(f, resp.Body)
        resp.Body.Close()
        if cerr := f.Close(); err == nil {
            err = cerr
        }
        return err
    }
}

Against the stub, three URLs with one 429 among them produced three files, and a list with one invalid URL returned 400 invalid_url and stopped the group. Choose limit from your plan's concurrency limit in the rate limits reference. The per-minute limit belongs to the account and all its keys share it, so two services that each fan out at full width will slow each other down.

A net/http handler that streams the image

var slugRE = regexp.MustCompile(`^[a-z0-9-]+$`)

// ogImage streams the rendered PNG to the client without buffering it.
// If the visitor disconnects, r.Context() cancels the upstream render.
func ogImage(w http.ResponseWriter, r *http.Request) {
    slug := r.PathValue("slug")
    if !slugRE.MatchString(slug) {
        http.NotFound(w, r)
        return
    }
    resp, err := shoot(r.Context(), map[string]any{
        "url":    "https://example.com/blog/" + slug,
        "preset": "og_card",
    })
    if err != nil {
        var apiErr *APIError
        status := http.StatusBadGateway
        if errors.As(err, &apiErr) && apiErr.Status == http.StatusTooManyRequests {
            status = http.StatusServiceUnavailable
        }
        log.Printf("og %s: %v", slug, err)
        http.Error(w, http.StatusText(status), status)
        return
    }
    defer resp.Body.Close()
    w.Header().Set("Content-Type", resp.Header.Get("Content-Type"))
    w.Header().Set("Cache-Control", "public, max-age=86400")
    if _, err := io.Copy(w, resp.Body); err != nil {
        log.Printf("og %s: copy: %v", slug, err)
    }
}

Register it with mux.HandleFunc("GET /og/{slug}", ogImage). The slug check stops visitors from using your key on arbitrary pages.

A signed URL in html/template

When the crawler can fetch the image directly, you do not need the handler at all. A signed URL carries your public key ID and an HMAC signature, never the API key:

var page = template.Must(template.New("post").Parse(
    `<meta property="og:image" content="{{.OGImage}}">` + "\n"))

func ogImageURL(signer *rs.Client, pageURL string) (string, error) {
    // Truncate to the day so the URL is stable for 24 hours; 7-8 days valid.
    expires := time.Now().UTC().Truncate(24 * time.Hour).Add(8 * 24 * time.Hour)
    return signer.GenerateURL(rs.URL(pageURL).Preset("og_card"), expires, "", "")
}

Create signer with rs.WithSigningKey(os.Getenv("RS_SIGNING_KEY")) and rs.WithPublicKeyID(os.Getenv("RS_PUBLIC_KEY_ID")). html/template writes each & as &amp; inside the attribute, which is correct. Day-aligned expiry matters: time.Now().Add(...) would give a new signature, and so a new image URL, on every page view. The maximum lifetime is 30 days.

Batch with the SDK

For up to 10 URLs, POST /v1/batch answers in one synchronous call:

batch, err := client.Batch(ctx,
    []string{"https://example.com/", "https://example.com/pricing", "https://example.com/docs"},
    rs.URL("").Preset("og_card"))
if err != nil {
    log.Fatal(err)
}
fmt.Printf("%d/%d rendered\n", batch.Completed, batch.Total)
for _, item := range batch.Results {
    if item.Status == "completed" {
        fmt.Println(item.URL, "->", item.Image.ImageURL)
    } else {
        fmt.Println(item.URL, "failed:", item.Error)
    }
}

Larger batches come back as processing; poll client.GetBatch(ctx, id).

Test with httptest

Point apiBase at an httptest.Server and drive the handler with a recorder. No network, no credits:

func fakeAPI(t *testing.T, h http.HandlerFunc) {
    t.Helper()
    srv := httptest.NewServer(h)
    t.Cleanup(srv.Close)
    old := apiBase
    apiBase = srv.URL
    t.Cleanup(func() { apiBase = old })
}

func serve(path string) *httptest.ResponseRecorder {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /og/{slug}", ogImage)
    rec := httptest.NewRecorder()
    mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
    return rec
}

func TestOGImageMapsRateLimitTo503(t *testing.T) {
    fakeAPI(t, func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Retry-After", "30")
        w.WriteHeader(http.StatusTooManyRequests)
        w.Write([]byte(`{"error":{"code":"too_many_requests","message":"slow down"}}`))
    })
    if rec := serve("/og/hello-world"); rec.Code != http.StatusServiceUnavailable {
        t.Fatalf("got %d", rec.Code)
    }
}

func TestShootHonoursCancel(t *testing.T) {
    fakeAPI(t, func(w http.ResponseWriter, r *http.Request) {
        io.Copy(io.Discard, r.Body) // lets the server notice the client going away
        select { // a slow render
        case <-time.After(5 * time.Second):
        case <-r.Context().Done():
        }
    })
    ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
    defer cancel()
    _, err := shoot(ctx, map[string]any{"url": "https://example.com"})
    if !errors.Is(err, context.DeadlineExceeded) {
        t.Fatalf("want DeadlineExceeded, got %v", err)
    }
}

The cancel test is the one worth keeping. Without the io.Copy of the request body, the fake server did not notice the client leaving, and the test took the full 5 seconds instead of 0.11. For code that uses the SDK, rs.WithBaseURL(srv.URL) points the client at the same kind of fake; the SDK testing guide has more.

Source, changelog and next steps

Get a free API key to run these programs against the real API. The free plan has 150 credits a month.

Frequently asked questions

Is there a Go library for the RenderScreenshot API?
Yes. go get github.com/Render-Screenshot/rs-go installs the official SDK. It has no dependencies outside the standard library and needs Go 1.21 or later.
Does rs-go cancel a request when the context is cancelled?
Not in v1.1.1. The methods accept a context.Context but do not attach it to the HTTP request, so WithTimeout is the only bound. Use http.NewRequestWithContext against the API when you need cancellation.
Does WithMaxRetries retry a 429?
No. The API sends the code too_many_requests on a 429, and the SDK's IsRetryable() does not include that code, so the built-in retry stops. Check rs.IsRateLimited(err) and wait RetryAfter seconds yourself.
How do I serve a screenshot from a Go HTTP handler without buffering it?
Make the API request with r.Context(), then io.Copy the response body into the http.ResponseWriter. The image streams through, and a visitor who disconnects cancels the upstream render.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.