Background jobs (asynq)

Queue work, retry on failure, schedule with cron — without blocking requests.

9 minmedium

Some work shouldn't happen inside a request: sending emails, resizing images, generating PDFs, syncing third-party data. Background jobs handle it asynchronously — your handler enqueues, returns instantly, a worker processes later. Grit ships asynq as the job engine.

Why we need it

Three reasons to push work to a job:

  • Speed up requests. Sending email = 200ms; the user shouldn't wait. Enqueue, return in 5ms.
  • Retries on failure. External API hiccupped? The job retries automatically with backoff. The handler would have just 500'd.
  • Scheduled work. Cron-like — nightly digest, hourly cleanup, weekly report. Without a job system, you spawn a cron container that's hard to monitor.

The two halves

Jobs architecture

┌─────────────────┐ ┌─────────────────┐ │ API process │ │ Worker process │ │ │ │ │ │ handler │ │ process(job) │ │ │ │ │ │ │ │ │ enqueue │ │ │ run handler│ │ ▼ │ │ ▼ │ │ ┌──────────┐ │ ┌──────────────────┐ │ ┌──────────┐ │ │ │ asynq │───┼──►│ Redis queue │◄──┼──│ asynq │ │ │ │ Client │ │ │ (jobs:default) │ │ │ Worker │ │ │ └──────────┘ │ └──────────────────┘ │ └──────────┘ │ └─────────────────┘ └─────────────────┘
Producer and consumer separated by Redis. Workers run in a separate process from the API.

The API enqueues; the worker dequeues. They're separate Go processes. Both connect to Redis. You can scale workers independently — 10x more worker pods if your queue grows without touching the API.

Where it lives

apps/api/internal/jobs/
├── client.go        ← Enqueue(taskName, payload)
├── handlers.go      ← Register handlers for each task name
├── server.go        ← Worker process bootstrap
└── cron.go          ← Cron schedule definitions
cmd/worker/main.go   ← Worker entry point (separate binary)

How it's implemented

apps/api/internal/jobs/client.go
package jobs
import (
"context"
"encoding/json"
"github.com/hibiken/asynq"
)
type Client struct {
inner *asynq.Client
}
func NewClient(redisURL string) *Client {
opt, _ := asynq.ParseRedisURI(redisURL)
return &Client{inner: asynq.NewClient(opt)}
}
func (c *Client) Enqueue(ctx context.Context, taskName string, payload any) error {
b, err := json.Marshal(payload)
if err != nil { return err }
_, err = c.inner.EnqueueContext(ctx, asynq.NewTask(taskName, b))
return err
}
func (c *Client) EnqueueIn(ctx context.Context, taskName string, payload any, delay time.Duration) error {
b, _ := json.Marshal(payload)
_, err := c.inner.EnqueueContext(ctx, asynq.NewTask(taskName, b), asynq.ProcessIn(delay))
return err
}

Two operations: enqueue now, enqueue in delay (great for "send reminder in 24h").

Defining a job handler

apps/api/internal/jobs/handlers.go
type Handlers struct {
mail *mail.Service
store *storage.Service
}
func (h *Handlers) Register(mux *asynq.ServeMux) {
mux.HandleFunc("send_welcome", h.SendWelcome)
mux.HandleFunc("resize_image", h.ResizeImage)
mux.HandleFunc("cleanup_uploads", h.CleanupUploads)
}
func (h *Handlers) SendWelcome(ctx context.Context, t *asynq.Task) error {
var p struct{ To, Name string }
if err := json.Unmarshal(t.Payload(), &p); err != nil {
return fmt.Errorf("bad payload: %w", err)
}
return h.mail.Send(ctx, p.To, "welcome.html", "Welcome!", p)
}

Each task name maps to a handler function. Return nil for success; return an error to trigger retry. asynq re-runs the job up to N times with exponential backoff, then dead-letters it.

The worker process

cmd/worker/main.go
func main() {
cfg := config.Load()
db := db.MustOpen(cfg)
services := services.New(db, cfg)
srv := asynq.NewServer(
asynq.RedisClientOpt{Addr: cfg.RedisAddr},
asynq.Config{
Concurrency: 10, // workers per process
Queues: map[string]int{
"critical": 6, // priority weight
"default": 3,
"low": 1,
},
},
)
mux := asynq.NewServeMux()
jobs.NewHandlers(services).Register(mux)
log.Fatal(srv.Run(mux))
}

Run this as a separate process: go run cmd/worker/main.go in dev, or a separate deploy unit in prod. Two processes, one DB, one Redis.

Cron — scheduled jobs

apps/api/internal/jobs/cron.go
func RegisterCron(s *asynq.Scheduler) error {
// Every day at 2am
_, err := s.Register("0 2 * * *", asynq.NewTask("nightly_digest", nil))
if err != nil { return err }
// Every hour
_, err = s.Register("0 * * * *", asynq.NewTask("cleanup_uploads", nil))
if err != nil { return err }
return nil
}

Standard cron syntax. The scheduler runs as part of the worker; it enqueues the task at the right time, the same workers process it.

Cron in two processes = double-fire. If you run the scheduler in 2 worker pods, both will enqueue the same nightly digest. Run the scheduler in EXACTLY ONE process (an env flag like RUN_SCHEDULER=true on one worker) or you'll send 2× emails on cron days.

The Jobs admin page

Grit's admin panel at /admin/system/jobs shows:

  • Queue depth per queue (active, pending, retry, dead).
  • Recent runs with status + duration.
  • Failed jobs — click to inspect payload + error, re-queue.
  • Throughput chart (jobs/min).

When something's wrong, this is the first place to look.

How you call it from a service

func (s *OrderService) Create(ctx context.Context, in CreateOrderInput) (Order, error) {
order := models.Order{...}
if err := s.db.WithContext(ctx).Create(&order).Error; err != nil { return Order{}, err }
// 3 jobs, fire-and-forget — never block the create
_ = s.jobs.Enqueue(ctx, "send_receipt", map[string]any{"order_id": order.ID})
_ = s.jobs.Enqueue(ctx, "update_inventory", map[string]any{"order_id": order.ID})
_ = s.jobs.EnqueueIn(ctx, "ask_for_review", map[string]any{"order_id": order.ID}, 7*24*time.Hour)
return order, nil
}

Receipt: now. Inventory update: now. Review request: in 7 days. Customer's checkout response still returns in 50ms.

How to modify this battery

  • Add a new task — register the handler in handlers.go, enqueue it from a service. That's it.
  • Add a queue priority — edit the Queues map in server.go. Higher weight = more worker attention.
  • Increase concurrency — bump Concurrency: 10 to 50. Watch DB connection pool — if you exhaust it, raise the pool too.
  • Change retry policy — pass asynq.MaxRetry(3) at enqueue, or set a default in the server config.

Quick check

A job to resize an uploaded image fails because the S3 bucket is temporarily unreachable. With Grit's default config, what happens?

Try it

Wire your first custom job:

  1. Define a task: "send_birthday_email" that takes a user_id payload.
  2. Register the handler — it loads the user, sends an email via the mail service.
  3. Enqueue it from a test endpoint POST /api/dev/send-birthday/:user_id.
  4. Boot the worker: go run cmd/worker/main.go in a second terminal.
  5. Hit the endpoint. Watch the worker logs — should see the job processed. Mailhog should show the email.
  6. Bonus: make the handler return an error on purpose once. Confirm it retries (asynq logs retry attempts).

What's next

Last battery — AI (Claude + OpenAI). Streaming chat, embeddings, and how Grit lets you swap providers without touching feature code.

Spot a typo? Have an idea?

Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled — suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.

Suggest an improvement on GitHub