Skip to content
gohacks.dev
APIs

Async Jobs in Go APIs With 202 Accepted and Polling

Run long work behind a Go API with 202 Accepted: a bounded worker pool, a pollable job resource, Retry-After, idempotency keys, cancellation, clean shutdown.

16 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response.

Some work does not fit inside a request. An export of a million rows, a report that joins six tables, a video that must be transcoded, an import that calls a slow third party. A synchronous endpoint for these either times out, holds a connection and a database transaction for minutes, or gets killed by a proxy that gives up after sixty seconds. The client sees an error for work that might still be running.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

HTTP has a standard answer: accept the request, say so with 202 Accepted, and give the client a resource it can look at to see how the work is going. This article builds that pattern in Go with the standard library: a job manager with a bounded worker pool, the HTTP endpoints for submit, status and cancel, a polite polling client, and a shutdown that does not lose or strand work. It is the "too big" option the bulk endpoints article and the large response article point to, and it pairs with webhooks for notifying the client when the job is done. Everything ran on Go 1.25.6.

The pattern

  1. The client sends POST /v1/exports.
  2. The server validates the request, queues a job and answers 202 Accepted with a body describing the job, a Location header pointing at /v1/jobs/{id}, and Retry-After suggesting when to look.
  3. The client polls GET /v1/jobs/{id} and sees queued, running with a progress figure, and finally succeeded with a link to the result, or failed with a reason.
  4. The client can DELETE /v1/jobs/{id} to cancel.

The semantics of 202 are deliberately vague: the request was understood and may or may not complete. That is exactly the honest answer, and it is why the status resource matters.

The job manager

jobs.goGo
package main

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"errors"
	"sync"
	"time"
)

type State string

const (
	Queued    State = "queued"
	Running   State = "running"
	Succeeded State = "succeeded"
	Failed    State = "failed"
	Canceled  State = "canceled"
)

var ErrQueueFull = errors.New("job queue is full")

type Job struct {
	ID        string    `json:"id"`
	State     State     `json:"status"`
	Progress  int       `json:"progress_percent"`
	Result    string    `json:"result,omitempty"`
	Error     string    `json:"error,omitempty"`
	CreatedAt time.Time `json:"-"`
	rows      int
	cancel    context.CancelFunc
}

// Manager runs jobs on a bounded worker pool. State lives in memory here; a production system stores jobs in a database
// so they survive a restart and can be seen by every instance.
type Manager struct {
	mu      sync.Mutex
	jobs    map[string]*Job
	byKey   map[string]string // idempotency key -> job id
	queue   chan *Job
	wg      sync.WaitGroup
	closing bool
	// Work is the long operation; it reports progress and must watch ctx.
	Work func(ctx context.Context, rows int, progress func(pct int)) (string, error)
}

func NewManager(workers, queueSize int, work func(context.Context, int, func(int)) (string, error)) *Manager {
	m := &Manager{jobs: map[string]*Job{}, byKey: map[string]string{}, queue: make(chan *Job, queueSize), Work: work}
	for i := 0; i < workers; i++ {
		m.wg.Add(1)
		go m.worker()
	}
	return m
}

func newID() string {
	b := make([]byte, 6)
	rand.Read(b)
	return "job_" + hex.EncodeToString(b)
}

// Submit queues a job and returns at once. The same idempotency key returns the job it created the first time.
func (m *Manager) Submit(rows int, key string) (Job, bool, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.closing {
		return Job{}, false, ErrQueueFull
	}
	if key != "" {
		if id, ok := m.byKey[key]; ok {
			return m.snapshot(m.jobs[id]), false, nil
		}
	}
	j := &Job{ID: newID(), State: Queued, CreatedAt: time.Now(), rows: rows}
	select {
	case m.queue <- j: // never block the request waiting for a free slot
	default:
		return Job{}, false, ErrQueueFull
	}
	m.jobs[j.ID] = j
	if key != "" {
		m.byKey[key] = j.ID
	}
	return m.snapshot(j), true, nil
}

func (m *Manager) snapshot(j *Job) Job { c := *j; c.cancel = nil; return c }

func (m *Manager) Get(id string) (Job, bool) {
	m.mu.Lock()
	defer m.mu.Unlock()
	j, ok := m.jobs[id]
	if !ok {
		return Job{}, false
	}
	return m.snapshot(j), true
}

// Cancel stops a queued or running job. Finished jobs are left alone.
func (m *Manager) Cancel(id string) (Job, bool) {
	m.mu.Lock()
	defer m.mu.Unlock()
	j, ok := m.jobs[id]
	if !ok {
		return Job{}, false
	}
	switch j.State {
	case Queued:
		j.State = Canceled
	case Running:
		j.cancel()
	}
	return m.snapshot(j), true
}

func (m *Manager) worker() {
	defer m.wg.Done()
	for j := range m.queue {
		m.mu.Lock()
		if j.State == Canceled { // canceled while it waited
			m.mu.Unlock()
			continue
		}
		ctx, cancel := context.WithCancel(context.Background())
		j.State, j.cancel = Running, cancel
		m.mu.Unlock()

		result, err := m.Work(ctx, j.rows, func(p int) { m.mu.Lock(); j.Progress = p; m.mu.Unlock() })

		m.mu.Lock()
		switch {
		case ctx.Err() != nil:
			j.State = Canceled
		case err != nil:
			j.State, j.Error = Failed, err.Error()
		default:
			j.State, j.Result, j.Progress = Succeeded, result, 100
		}
		m.mu.Unlock()
		cancel()
	}
}

// Shutdown stops accepting work and waits for running jobs to finish, then cancels whatever is left at the deadline.
func (m *Manager) Shutdown(ctx context.Context) {
	m.mu.Lock()
	m.closing = true
	close(m.queue)
	m.mu.Unlock()
	done := make(chan struct{})
	go func() { m.wg.Wait(); close(done) }()
	select {
	case <-done:
	case <-ctx.Done():
		m.mu.Lock()
		for _, j := range m.jobs {
			switch j.State {
			case Running:
				j.cancel()
			case Queued: // never started: do not start it now
				j.State = Canceled
			}
		}
		m.mu.Unlock()
		<-done
	}
}

The manager holds a map of jobs, a bounded channel as the queue, and a fixed number of workers. Seven decisions are visible in the code.

Bounded everything. Two workers and a queue of two mean at most four jobs are accepted at once. Unbounded queues turn a traffic spike into memory exhaustion and a ten-hour backlog. Submit uses a select with a default case, so when the queue is full it returns ErrQueueFull immediately instead of blocking the HTTP request waiting for a slot.

A job is a resource with a state machine. queued, running, then one of succeeded, failed or canceled. Clients depend on these names; keep the set small and document the transitions.

Idempotency. Submit takes an idempotency key. A second submit with the same key returns the job created the first time and reports created=false. This is the same discipline as in idempotency keys: a client that timed out on the 202 cannot know whether its POST arrived, and resending must not start a second export.

Cancellation through context. Each running job has a context.CancelFunc. The work function receives the context and must watch it; the example's select on ctx.Done() is the pattern. Cancel on a queued job marks it canceled and the worker skips it; on a running job it cancels the context, and the worker records canceled when the work returns. Work that ignores its context cannot be canceled, however nicely the API is built.

Progress. The work function receives a callback and reports a percentage. Clients like to show it, and it is the cheapest evidence a long job is alive.

Snapshots. Get returns a copy of the job taken under the lock. Handing out pointers into the map would let a handler read a job while a worker writes it.

Shutdown. Shutdown closes the queue so no new work enters, waits for the workers, and if the context's deadline passes first, cancels running jobs and marks queued ones canceled so the workers skip them. A server that exits without this loses in-flight work silently.

The HTTP layer

api.goGo
package main

import (
	"encoding/json"
	"net/http"
	"strconv"
)

func Handler(m *Manager) http.Handler {
	mux := http.NewServeMux()
	write := func(w http.ResponseWriter, status int, v any) {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(status)
		json.NewEncoder(w).Encode(v)
	}
	mux.HandleFunc("POST /v1/exports", func(w http.ResponseWriter, r *http.Request) {
		var in struct {
			Rows int `json:"rows"`
		}
		if json.NewDecoder(r.Body).Decode(&in) != nil || in.Rows < 1 || in.Rows > 1_000_000 {
			write(w, 422, map[string]string{"title": "rows must be between 1 and 1000000"})
			return
		}
		job, created, err := m.Submit(in.Rows, r.Header.Get("Idempotency-Key"))
		if err != nil {
			w.Header().Set("Retry-After", "5")
			write(w, 503, map[string]string{"title": "The export queue is full, try again shortly"})
			return
		}
		status := http.StatusAccepted
		if !created {
			status = http.StatusOK // the same request seen again: report the job it already made
		}
		w.Header().Set("Location", "/v1/jobs/"+job.ID)
		w.Header().Set("Retry-After", "1")
		write(w, status, job)
	})
	mux.HandleFunc("GET /v1/jobs/{id}", func(w http.ResponseWriter, r *http.Request) {
		job, ok := m.Get(r.PathValue("id"))
		if !ok {
			write(w, 404, map[string]string{"title": "no such job"})
			return
		}
		if job.State == Queued || job.State == Running {
			w.Header().Set("Retry-After", strconv.Itoa(1))
		}
		write(w, 200, job)
	})
	mux.HandleFunc("DELETE /v1/jobs/{id}", func(w http.ResponseWriter, r *http.Request) {
		job, ok := m.Cancel(r.PathValue("id"))
		if !ok {
			write(w, 404, map[string]string{"title": "no such job"})
			return
		}
		write(w, 202, job) // cancellation of a running job is asynchronous too
	})
	return mux
}

POST /v1/exports validates the input, calls Submit, and answers. A new job is 202 with Location and Retry-After. A repeat of the same idempotency key is 200 with the same job, to tell the client "nothing new started". A full queue is 503 with Retry-After: 5: the server is saying it is temporarily at capacity, which is the truthful status, and not 429, which is about the client's own rate.

GET /v1/jobs/{id} returns the job. While it is queued or running it includes Retry-After, so clients know how long to wait before asking again. The body for a finished job carries the result link or the error.

DELETE /v1/jobs/{id} cancels and answers 202, because cancelling a running job is also asynchronous: the request was accepted, and the state changes when the work notices.

When a job finishes, some APIs redirect the status URL to the result with 303 See Other. Returning the status body with a result link, as here, is simpler for clients and works with any result location.

The client side

Polling is where clients are rude. A tight loop at 10 requests a second per job multiplies load by the number of waiting clients.

client.goGo
package main

import (
	"context"
	"encoding/json"
	"net/http"
	"strconv"
	"time"
)

// Wait polls a job until it finishes. It honors Retry-After, backs off up to a cap, and stops with the context.
func Wait(ctx context.Context, c *http.Client, base, id string, onPoll func(Job)) (Job, error) {
	delay := 100 * time.Millisecond
	for {
		req, _ := http.NewRequestWithContext(ctx, "GET", base+"/v1/jobs/"+id, nil)
		res, err := c.Do(req)
		if err != nil {
			return Job{}, err
		}
		var j Job
		json.NewDecoder(res.Body).Decode(&j)
		res.Body.Close()
		if onPoll != nil {
			onPoll(j)
		}
		switch j.State {
		case Succeeded, Failed, Canceled:
			return j, nil
		}
		wait := delay
		if s, err := strconv.Atoi(res.Header.Get("Retry-After")); err == nil && time.Duration(s)*time.Second > wait {
			wait = min(time.Duration(s)*time.Second, 300*time.Millisecond) // the demo clamps it so the run stays short
		}
		select {
		case <-time.After(wait):
		case <-ctx.Done():
			return Job{}, ctx.Err()
		}
		delay = min(delay*2, 400*time.Millisecond)
	}
}

Wait starts with a short delay, doubles it up to a cap, and, importantly, honors the server's Retry-After when it asks for more. It stops with the context, so a caller can bound the total wait. The demo clamps the header's value so the run stays short; a real client would not.

What it did

main.goGo
package main

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"time"
)

// work pretends to export `rows` rows at 2,000 per 10 ms, reporting progress and obeying cancellation.
func work(ctx context.Context, rows int, progress func(int)) (string, error) {
	for done := 0; done < rows; done += 2000 {
		select {
		case <-time.After(10 * time.Millisecond):
		case <-ctx.Done():
			return "", ctx.Err()
		}
		progress(min(100, done*100/rows))
	}
	return fmt.Sprintf("/files/export-%d-rows.csv", rows), nil
}

func main() {
	m := NewManager(2, 2, work)
	srv := httptest.NewServer(Handler(m))
	defer srv.Close()
	c := srv.Client()

	post := func(rows int, key string) (*http.Response, Job) {
		body, _ := json.Marshal(map[string]int{"rows": rows})
		req, _ := http.NewRequest("POST", srv.URL+"/v1/exports", bytes.NewReader(body))
		if key != "" {
			req.Header.Set("Idempotency-Key", key)
		}
		res, err := c.Do(req)
		if err != nil {
			panic(err)
		}
		var j Job
		json.NewDecoder(res.Body).Decode(&j)
		res.Body.Close()
		return res, j
	}

	fmt.Println("== submit, then poll")
	t0 := time.Now()
	res, job := post(40_000, "exp-1")
	fmt.Printf("POST /v1/exports -> %d in %v  Location=%s  Retry-After=%s  status=%s\n", res.StatusCode, time.Since(t0).Round(100*time.Microsecond), res.Header.Get("Location"), res.Header.Get("Retry-After"), job.State)
	final, _ := Wait(context.Background(), c, srv.URL, job.ID, func(j Job) {
		fmt.Printf("  poll at %4dms: %-9s %3d%%\n", time.Since(t0).Milliseconds(), j.State, j.Progress)
	})
	fmt.Printf("finished after %v: %s result=%s\n", time.Since(t0).Round(10*time.Millisecond), final.State, final.Result)

	fmt.Println("\n== the same Idempotency-Key does not start a second job")
	res, again := post(40_000, "exp-1")
	fmt.Printf("POST again -> %d, same job: %v\n", res.StatusCode, again.ID == job.ID)

	fmt.Println("\n== cancel a long job")
	_, long := post(10_000_000/10, "")
	time.Sleep(50 * time.Millisecond)
	req, _ := http.NewRequest("DELETE", srv.URL+"/v1/jobs/"+long.ID, nil)
	dres, _ := c.Do(req)
	dres.Body.Close()
	canceled, _ := Wait(context.Background(), c, srv.URL, long.ID, nil)
	fmt.Printf("DELETE -> %d, final state: %s\n", dres.StatusCode, canceled.State)

	fmt.Println("\n== a full queue says so instead of waiting")
	codes := []int{}
	for i := 0; i < 8; i++ {
		res, _ := post(200_000, "")
		codes = append(codes, res.StatusCode)
	}
	fmt.Println("8 quick submits with 2 workers and room for 2 more:", codes, "Retry-After on the 503:", func() string {
		res, _ := post(1000, "")
		return res.Header.Get("Retry-After")
	}())

	fmt.Println("\n== graceful shutdown with a 150 ms deadline")
	for i := 0; i < 4; i++ {
		post(200_000, "")
	}
	ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
	defer cancel()
	t1 := time.Now()
	m.Shutdown(ctx)
	counts := map[State]int{}
	m.mu.Lock()
	for _, j := range m.jobs {
		counts[j.State]++
	}
	m.mu.Unlock()
	fmt.Printf("Shutdown returned after %v; jobs by state: %v\n", time.Since(t1).Round(10*time.Millisecond), counts)
}
Text
== submit, then poll
POST /v1/exports -> 202 in 1.6ms  Location=/v1/jobs/job_841133c3814b  Retry-After=1  status=queued
  poll at    1ms: running     0%
  poll at  306ms: succeeded 100%
finished after 310ms: succeeded result=/files/export-40000-rows.csv

== the same Idempotency-Key does not start a second job
POST again -> 200, same job: true

== cancel a long job
DELETE -> 202, final state: canceled

== a full queue says so instead of waiting
8 quick submits with 2 workers and room for 2 more: [202 202 202 202 503 503 503 503] Retry-After on the 503: 5

== graceful shutdown with a 150 ms deadline
Shutdown returned after 150ms; jobs by state: map[canceled:5 succeeded:1]

Submit and poll. The POST answered 202 in 1.6 ms with a Location and Retry-After: 1, while the export itself took about 310 ms. The first poll found it running; the next, 300 ms later, found it succeeded with its result path.

Idempotent resubmission. The same key returned 200 with the same job id.

Cancellation. A large export was started and cancelled 50 ms later; the DELETE answered 202 and the job ended in canceled.

Backpressure. Eight quick submits with two workers and a queue of two gave four 202s and four 503s, and the 503 told the client to come back in five seconds. A client with its own backoff handles this. A server that accepted everything would have eventually fallen over.

Shutdown. With a 150 ms deadline, Shutdown returned at 150 ms: running jobs were cancelled at the deadline, queued ones were marked canceled and never started, and the one finished job stayed succeeded.

Tests

jobs_test.goGo
package main

import (
	"context"
	"testing"
	"time"
)

func quick(ctx context.Context, rows int, progress func(int)) (string, error) {
	select {
	case <-time.After(5 * time.Millisecond):
		return "ok", nil
	case <-ctx.Done():
		return "", ctx.Err()
	}
}

func waitFor(t *testing.T, m *Manager, id string, want State) Job {
	t.Helper()
	for i := 0; i < 400; i++ {
		if j, _ := m.Get(id); j.State == want {
			return j
		}
		time.Sleep(5 * time.Millisecond)
	}
	j, _ := m.Get(id)
	t.Fatalf("job %s is %s, wanted %s", id, j.State, want)
	return j
}

func TestSubmitReturnsBeforeTheWorkIsDone(t *testing.T) {
	m := NewManager(1, 1, func(ctx context.Context, _ int, _ func(int)) (string, error) {
		time.Sleep(200 * time.Millisecond)
		return "late", nil
	})
	defer m.Shutdown(context.Background())
	t0 := time.Now()
	j, created, err := m.Submit(1, "")
	if err != nil || !created || time.Since(t0) > 50*time.Millisecond || (j.State != Queued && j.State != Running) {
		t.Fatalf("%v %v %v %+v", err, created, time.Since(t0), j)
	}
}

func TestIdempotencyKeyDoesNotCreateASecondJob(t *testing.T) {
	m := NewManager(1, 4, quick)
	defer m.Shutdown(context.Background())
	a, c1, _ := m.Submit(1, "k")
	b, c2, _ := m.Submit(1, "k")
	if a.ID != b.ID || !c1 || c2 {
		t.Fatalf("%+v %+v %v %v", a, b, c1, c2)
	}
}

func TestAFullQueueRefusesInsteadOfBlocking(t *testing.T) {
	block := make(chan struct{})
	m := NewManager(1, 1, func(ctx context.Context, _ int, _ func(int)) (string, error) { <-block; return "", nil })
	defer func() { close(block); m.Shutdown(context.Background()) }()
	m.Submit(1, "") // taken by the worker
	time.Sleep(20 * time.Millisecond)
	m.Submit(1, "") // fills the queue
	t0 := time.Now()
	if _, _, err := m.Submit(1, ""); err != ErrQueueFull || time.Since(t0) > 50*time.Millisecond {
		t.Fatalf("%v after %v", err, time.Since(t0))
	}
}

func TestCancelStopsARunningJob(t *testing.T) {
	m := NewManager(1, 1, func(ctx context.Context, _ int, _ func(int)) (string, error) {
		<-ctx.Done()
		return "", ctx.Err()
	})
	defer m.Shutdown(context.Background())
	j, _, _ := m.Submit(1, "")
	waitFor(t, m, j.ID, Running)
	m.Cancel(j.ID)
	waitFor(t, m, j.ID, Canceled)
}

func TestAQueuedJobCanBeCanceledBeforeItRuns(t *testing.T) {
	block := make(chan struct{})
	m := NewManager(1, 2, func(ctx context.Context, _ int, _ func(int)) (string, error) { <-block; return "", nil })
	first, _, _ := m.Submit(1, "")
	waitFor(t, m, first.ID, Running)
	second, _, _ := m.Submit(1, "")
	m.Cancel(second.ID)
	close(block)
	m.Shutdown(context.Background())
	if j, _ := m.Get(second.ID); j.State != Canceled {
		t.Fatalf("state %s", j.State)
	}
}

func TestShutdownWaitsForRunningWork(t *testing.T) {
	m := NewManager(1, 1, quick)
	j, _, _ := m.Submit(1, "")
	m.Shutdown(context.Background())
	if got, _ := m.Get(j.ID); got.State != Succeeded {
		t.Fatalf("state %s", got.State)
	}
}
Text
=== RUN   TestSubmitReturnsBeforeTheWorkIsDone
--- PASS: TestSubmitReturnsBeforeTheWorkIsDone (0.20s)
=== RUN   TestIdempotencyKeyDoesNotCreateASecondJob
--- PASS: TestIdempotencyKeyDoesNotCreateASecondJob (0.01s)
=== RUN   TestAFullQueueRefusesInsteadOfBlocking
--- PASS: TestAFullQueueRefusesInsteadOfBlocking (0.02s)
=== RUN   TestCancelStopsARunningJob
--- PASS: TestCancelStopsARunningJob (0.01s)
=== RUN   TestAQueuedJobCanBeCanceledBeforeItRuns
--- PASS: TestAQueuedJobCanBeCanceledBeforeItRuns (0.01s)
=== RUN   TestShutdownWaitsForRunningWork
--- PASS: TestShutdownWaitsForRunningWork (0.01s)
PASS
ok  	example.test/lab	1.534s

The tests cover what is easy to regress: submit returns before the work is done, the idempotency key dedupes, a full queue refuses without blocking, running and queued jobs can be cancelled, and shutdown waits for work in flight. They use channels and short sleeps in place of the clock; for tests with timers, see testing with synctest.

What this version leaves out

The manager keeps jobs in memory, which is fine for a lab and wrong for production in four ways.

Restarts lose jobs. Persist job rows in a database, with state, input, progress and result location. On start-up, requeue jobs that were running when the process died; the work must therefore be safe to run twice, or checkpoint its progress.

One process only. With several instances, a job submitted to one must be visible to all, and exactly one worker must claim it. A database queue with SELECT ... FOR UPDATE SKIP LOCKED (PostgreSQL) or a dedicated queue (Redis streams, NATS, a cloud queue) does that. Dedicated systems like Temporal or Asynq take on retries, scheduling and visibility if you need more than this.

Results need a home and a lifetime. Write the output to object storage and give the client a signed, expiring URL. Delete results and old job rows on a schedule.

Failures need a policy. Decide which errors retry, how many times, with what backoff, and what a client sees after the final failure. The job resource should say failed with an error the client can act on, and nothing that leaks internals.

Authorization. A job id is a capability only if unguessable and checked. Tie each job to its owner and verify the caller on every poll and cancel (broken access control).

Alternatives to polling

Webhooks: the server calls the client when the job ends. No polling, and the client needs a public endpoint (webhook design). Offer both: polling is the fallback every client can use.

Server-sent events: the client holds a connection and receives progress as it happens (SSE in Go). Good for browsers watching a job.

Long polling: the status request waits up to some seconds for a change before answering (long polling), cutting the number of requests without the machinery of SSE.

Whichever you add, keep the job resource. It is the source of truth the other mechanisms point at. The pattern is described in the HTTP specification for 202 Accepted and Retry-After, and cancellation through contexts is covered in the context package documentation.

Taggedproject
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.