Background jobs (asynq)
Queue work, retry on failure, schedule with cron — without blocking requests.
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
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
package jobsimport ("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
type Handlers struct {mail *mail.Servicestore *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
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 processQueues: 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
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.
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
Queuesmap inserver.go. Higher weight = more worker attention. - Increase concurrency — bump
Concurrency: 10to 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
Try it
Wire your first custom job:
- Define a task: "send_birthday_email" that takes a
user_idpayload. - Register the handler — it loads the user, sends an email via the mail service.
- Enqueue it from a test endpoint
POST /api/dev/send-birthday/:user_id. - Boot the worker:
go run cmd/worker/main.goin a second terminal. - Hit the endpoint. Watch the worker logs — should see the job processed. Mailhog should show the email.
- 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