Long Running Operations as Resources in Go APIs Explained
Model slow work in a Go API as an operation resource with done, metadata, response and error fields, and let clients wait on it instead of polling.

Most APIs have one endpoint that outgrows the request. An import, a report, a backup, a resize across a few thousand images. The first version does the work inline and the first timeout in front of the server exposes the mistake. The second version returns a job id, and every team invents a slightly different job shape: status, state, progress, result_url, error_message, each spelled a different way on each endpoint.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
A long running operation, usually shortened to LRO, is the fix for the second problem. It is a convention for one resource shape that every slow method in the API returns, so clients write the waiting code once. Google's API design guide describes it and the google.longrunning.Operation message is the best known version. This article takes the idea, shrinks it to JSON over HTTP, and builds it in Go. It sits beside the 202 Accepted job queue, which covers worker pools and cancellation, and the webhook design article for the push alternative. Everything ran on Go 1.25.6.
The shape of an operation
An operation has five fields and each one earns its place.
| Field | Meaning |
|---|---|
name | The operation's address, such as operations/op-001. Clients store this and nothing else |
done | false while running, true once it has finished one way or the other |
metadata | Anything the client may want while waiting: progress, the input it asked for, timestamps |
response | Present when done is true and the work succeeded. Its shape is the same as the method would have returned synchronously |
error | Present when done is true and the work failed. A code and a message |
The important rule is that response and error are mutually exclusive and only appear after done flips. A client then has a single loop: fetch the operation until done, then look at which of the two arrived. Compare that to a status enum with five values where the client has to know which of them are terminal.
Keeping done as a boolean instead of reading it off a status string is deliberate. New intermediate states (validating, uploading) can be added to metadata later without breaking any client that only asks "is it finished".
The shape of the endpoints
The starting method returns the operation itself, not a job id wrapped in a different envelope. The status code is 202 Accepted and the Location header points at the operation. Method names follow the custom method style: POST /v1/reports:generate.
The operation lives under its own collection, GET /v1/operations/{id}. It is one collection for the whole API, not one per feature, which is the point: a client library has a single Wait(name) function.
A runnable version
The lab below keeps operations in memory, starts a fake 80 ms report, and adds one idea beyond plain polling: ?wait=2s. When the operation is not done, the handler holds the request open until it finishes or the wait runs out, then answers 200 either way with whatever state it has. A timeout is not an error, it is just an operation that is still running.
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"time"
)
// Operation is the resource a client polls. Exactly one of Response or Error is set once Done is true.
type Operation struct {
Name string `json:"name"`
Done bool `json:"done"`
Metadata map[string]any `json:"metadata,omitempty"`
Response json.RawMessage `json:"response,omitempty"`
Error *OpError `json:"error,omitempty"`
}
type OpError struct {
Code int `json:"code"`
Message string `json:"message"`
}
type store struct {
mu sync.Mutex
ops map[string]*Operation
wake map[string]chan struct{}
seq int
}
func newStore() *store {
return &store{ops: map[string]*Operation{}, wake: map[string]chan struct{}{}}
}
func (s *store) create(meta map[string]any) Operation {
s.mu.Lock()
defer s.mu.Unlock()
s.seq++
name := fmt.Sprintf("operations/op-%03d", s.seq)
s.ops[name] = &Operation{Name: name, Metadata: meta}
s.wake[name] = make(chan struct{})
return *s.ops[name]
}
func (s *store) finish(name string, resp any, opErr *OpError) {
s.mu.Lock()
defer s.mu.Unlock()
op := s.ops[name]
op.Done = true
if opErr != nil {
op.Error = opErr
} else {
op.Response, _ = json.Marshal(resp)
}
close(s.wake[name]) // everyone waiting on this operation wakes at once
}
func (s *store) get(name string) (Operation, <-chan struct{}, bool) {
s.mu.Lock()
defer s.mu.Unlock()
op, ok := s.ops[name]
if !ok {
return Operation{}, nil, false
}
return *op, s.wake[name], true
}
func main() {
s := newStore()
mux := http.NewServeMux()
mux.HandleFunc("POST /v1/reports:generate", func(w http.ResponseWriter, r *http.Request) {
op := s.create(map[string]any{"kind": "report", "rows": 5000})
go func() { // stand-in for the real work
time.Sleep(80 * time.Millisecond)
s.finish(op.Name, map[string]string{"url": "https://files.example.test/report-1.csv"}, nil)
}()
w.Header().Set("Location", "/v1/"+op.Name)
w.WriteHeader(http.StatusAccepted)
json.NewEncoder(w).Encode(op)
})
mux.HandleFunc("GET /v1/operations/{id}", func(w http.ResponseWriter, r *http.Request) {
op, done, ok := s.get("operations/" + r.PathValue("id"))
if !ok {
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
return
}
// ?wait=2s holds the request until the operation finishes or the wait runs out; both answer 200.
if wait, err := time.ParseDuration(r.URL.Query().Get("wait")); err == nil && !op.Done {
ctx, cancel := context.WithTimeout(r.Context(), min(wait, 10*time.Second))
defer cancel()
select {
case <-done:
case <-ctx.Done():
}
op, _, _ = s.get("operations/" + r.PathValue("id"))
}
json.NewEncoder(w).Encode(op)
})
srv := httptest.NewServer(mux)
defer srv.Close()
show := func(label string, resp *http.Response, err error) {
if err != nil {
panic(err)
}
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
fmt.Printf("%-22s %d %s", label, resp.StatusCode, strings.TrimSpace(string(b)))
if loc := resp.Header.Get("Location"); loc != "" {
fmt.Printf(" [Location: %s]", loc)
}
fmt.Println()
}
resp, err := http.Post(srv.URL+"/v1/reports:generate", "application/json", nil)
show("start", resp, err)
resp, err = http.Get(srv.URL + "/v1/operations/op-001")
show("poll right away", resp, err)
t0 := time.Now()
resp, err = http.Get(srv.URL + "/v1/operations/op-001?wait=2s")
show("poll with wait=2s", resp, err)
fmt.Println("waited under 500ms:", time.Since(t0) < 500*time.Millisecond)
resp, err = http.Get(srv.URL + "/v1/operations/op-404")
show("unknown operation", resp, err)
}start 202 {"name":"operations/op-001","done":false,"metadata":{"kind":"report","rows":5000}} [Location: /v1/operations/op-001]
poll right away 200 {"name":"operations/op-001","done":false,"metadata":{"kind":"report","rows":5000}}
poll with wait=2s 200 {"name":"operations/op-001","done":true,"metadata":{"kind":"report","rows":5000},"response":{"url":"https://files.example.test/report-1.csv"}}
waited under 500ms: true
unknown operation 404 {"error":"not found"}The finish method closes a channel per operation. Every request waiting on that operation wakes at the same moment, and nothing polls a map in a loop. The map and the channel map are guarded by one mutex, and get returns a copy so no handler reads a struct another goroutine is writing.
What the run shows
The first request got 202 and a Location header, with done: false and the metadata the server chose to echo back. Polling right away returned the same thing, as it should. The third request is the interesting one: it asked to wait up to two seconds, the job needed about 80 ms, and the response arrived done: true with the report URL in response, in well under half a second. The last line is the waited under 500ms check, which is how I know the handler woke on the channel and did not sit out the full two seconds.
An unknown name returns a plain 404. Do not return an empty operation or a "not found" operation with done: true: a client that sees a finished operation will believe the work happened.
Why wait beats a fast poll loop
A client that polls every 200 ms for a minute sends 300 requests. Each one authenticates, takes a rate limit slot and shows up in your logs. A client that sends wait=30s sends two. The server pays for the held connection instead, which on Go is cheap: a goroutine blocked on a channel and a context costs a few kilobytes.
Cap the wait on the server. The lab uses min(wait, 10*time.Second) because proxies in front of you have their own idle timeouts, and a held request that outlives the proxy gets cut with a confusing 502. Pick a maximum comfortably below the shortest timeout on the path, and return 200 with done: false when it expires so the client asks again. This is long polling with a standard shape; the long polling article covers the connection details.
Always select on the request context too. When the client gives up and disconnects, r.Context() is cancelled and the handler returns instead of waiting for an operation nobody is watching.
Errors belong to the operation, not the poll
There are two different failures and they must not share a status code. If the poll request itself is wrong (unknown name, bad token, malformed wait) it is an HTTP error: 404, 401, 400. If the work failed, the poll succeeded. It returns 200 with done: true and an error object, because the operation exists and is reporting its result honestly.
A client that treats a 500 on the poll as "the job failed" will retry the whole job, perhaps doing the work twice. A client that treats error in a 200 as a transport problem will poll forever. Make the contract explicit in your docs and in the example client.
The error object should reuse your normal problem details format so clients already know how to display it. Use stable machine codes in it; the message is for humans.
Retention and ownership
Operations are data and they need a lifetime. Keep a finished operation for a day or a week, then delete it and return 404, and say so in the API documentation. Result files referenced from response should expire on the same schedule or sooner.
Every operation also belongs to a caller. Check ownership on every GET and every cancel, exactly as for any other resource, because an operation name is an identifier and not a secret. The IDOR article explains why a guessable or leaked id turns into a data leak when ownership is not checked. Random identifiers help, authorization is what actually protects.
Idempotent starts
If the network drops after a client sends POST /v1/reports:generate, the client cannot know whether an operation exists. Accept an Idempotency-Key header on the start method and return the existing operation for a repeated key, as described in idempotency keys. The operation name then works as the dedupe record the client can always recover.
When the pattern is too much
Not every slow call needs this. If the work usually takes under a second and occasionally takes ten, a synchronous endpoint with a sensible timeout is simpler. If the work is a single resource changing state, such as an order moving to shipped, a status field on the resource itself is clearer than a parallel operation object. The operation resource pays off when the work is genuinely detached from any one resource, when many methods across the API are slow, or when you want one client helper for all of them.
Whichever shape you pick, state in the documentation how long clients should expect to wait, how long a finished operation stays readable, and what a cancelled operation looks like. The status code semantics are specified in RFC 9110 for 202 Accepted, and the Go side of cancellation and timeouts is in the context package documentation.


