Background jobs (Asynq)
Enqueue, retry, schedule.
Some work shouldn't block the HTTP request â sending an email, resizing an image, calling an LLM. Grit uses Asynq for background jobs: enqueue from anywhere, a worker process consumes them. This lesson covers the pattern + the four primitives you'll actually use.
How it's shaped
Producer (your handler/service)â jobs.Enqueue(ctx, task) puts JSON into Redisreturns immediatelyWorker (separate Go process)â polls Redis, dequeues tasksâ calls the registered handler for the task typeâ marks done / retries on error / moves to dead-letter
Two processes â the API and the worker â share Redis as the queue. Both run from the same binary; you start the worker with ./api worker instead of ./api server.
Defining a task
const TaskTypeWelcomeEmail = "user:welcome_email"type WelcomeEmailPayload struct {UserID string `json:"user_id"`}func (j *Jobs) EnqueueWelcomeEmail(ctx context.Context, userID string) error {payload, _ := json.Marshal(WelcomeEmailPayload{UserID: userID})_, err := j.client.EnqueueContext(ctx, asynq.NewTask(TaskTypeWelcomeEmail, payload),asynq.Queue("default"),asynq.MaxRetry(5),)return err}
Handling the task in the worker
func (j *Jobs) HandleWelcomeEmail(ctx context.Context, task *asynq.Task) error {var p WelcomeEmailPayloadif err := json.Unmarshal(task.Payload(), &p); err != nil {return fmt.Errorf("unmarshal: %w", err)}user, err := j.userService.GetByID(ctx, p.UserID)if err != nil {return fmt.Errorf("get user: %w", err)}return j.mailer.SendWelcome(ctx, user.Email, user.Name)}
Return nil = task completed. Return an error = Asynq retries with exponential backoff up to MaxRetry, then moves it to the dead-letter queue.
Enqueuing from a handler
// After register succeedsif err := h.jobs.EnqueueWelcomeEmail(c.Request.Context(), user.ID); err != nil {// Log + continue â registration succeeded; the welcome email is best-effortlog.Printf("welcome email enqueue: %v", err)}respond.Created(c, user, "Account created")
Notice â enqueue failure doesn't fail the request. The user's account exists; the welcome email is best-effort. Different behaviour for critical paths (payments, audit) where you'd return the error.
Scheduled tasks (cron)
For recurring work (daily digests, hourly metrics roll-up):
// In internal/jobs/scheduler.goscheduler.Register("0 9 * * *", "daily:digest", nil) // 9 AM dailyscheduler.Register("0 * * * *", "metrics:rollup", nil) // hourly
Same handler signature as one-off tasks. The scheduler runs as part of the worker process â no separate cron daemon.
./api worker on its own container/VM/systemd unit.The Asynq admin UI
Grit's scaffold includes /admin/jobs in the admin app â visual dashboard for active/pending/failed/dead jobs, with retry + delete buttons. For the API-only kit, expose Asynq's web UI on a dedicated port; password-protected via the Sentinel dashboard credentials.
Quick check
Try it
Wire your first job. Register a new task type:
- Define
TaskTypeLogHello+ a handler that justlog.Println("hello from a job!"). - Add an enqueue call to your
/api/healthhandler. - Start the worker:
go run ./cmd/server worker(or whatever the scaffolded subcommand is). - Hit
/api/healthâ you should see "hello from a job!" in the worker's output.
What's next
Transactional email is the most common job. Next lesson â how to send it via Resend and watch it land in Mailhog during dev.
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