Courses/Grit Web/Background Jobs & Email
Course 6 of 8~30 min14 challenges

Background Jobs & Email

Some tasks are too slow or too unreliable to run while a user waits for a response. In this course, you will learn how Grit uses Redis-backed job queues to process work in the background — sending emails, processing images, generating reports — and how to send beautiful transactional emails with templates.


What are Background Jobs?

When a user clicks "Send Email" in your app, what should happen? If you send the email right there in the API handler, the user has to wait — maybe 2-5 seconds — while the email service processes the request. That's a terrible experience.

Instead, the smart approach is: the API handler puts a message on a queue saying"send this email", responds to the user immediately with "Email queued!", and a separate worker picks up that message and sends the email in the background. The user never waits.

Background Job: A task that runs asynchronously — outside the user's HTTP request cycle. The API responds immediately, and the actual work happens later in a separate process. This keeps the user interface fast and responsive.
Job Queue: A data structure (usually backed by Redis) that holds a list of tasks waiting to be processed. Producers add tasks to the queue, and workers consume them one at a time. If a task fails, it can be retried automatically.

Here are common tasks that should be background jobs instead of blocking the user's request:

  • Sending emails — welcome emails, password resets, order confirmations
  • Processing images — generating thumbnails, resizing uploads, optimizing file sizes
  • Generating reports — PDF exports, CSV downloads, analytics calculations
  • Cleanup tasks — removing orphaned files, purging expired sessions, archiving old data
  • Third-party API calls — payment webhooks, push notifications, syncing with external services
A good rule of thumb: if a task takes more than 200ms or depends on an external service that might be slow or unreliable, it should be a background job.
1

Challenge: Identify Background Job Candidates

Name 3 things in a web app that should be background jobs instead of blocking the user's request. For each one, explain why it would be bad to run it inline during the HTTP request.

How asynq Works

Grit uses asynq — a Go library for distributed task processing backed by Redis. It's simple, fast, and battle-tested. Here's how the pieces fit together:

asynq: A Go library for background task processing. It uses Redis as a message broker to coordinate between producers (your API handlers) and consumers (worker processes). Think of it as Sidekiq for Go.
Worker: A long-running process that listens to the Redis queue, picks up tasks, and processes them. Workers run separately from your API server — they're their own process.
Redis (as Job Broker): Redis acts as the middleman between your API and your workers. When a handler enqueues a task, it gets stored in Redis. Workers poll Redis for new tasks. Redis ensures tasks aren't lost and tracks their state (pending, active, completed, failed).

The flow works in 4 steps:

  1. 1.Handler dispatches a task — Your API handler creates a task with a type and payload, then pushes it to Redis via the asynq client.
  2. 2.Worker picks it up — The asynq worker process polls Redis, sees the new task, and dequeues it.
  3. 3.Worker processes it — The worker matches the task type to a registered handler function and executes it.
  4. 4.Task marked done/failed — If the handler returns nil, the task is marked as completed. If it returns an error, it's marked as failed and may be retried.
Architecture Diagram
┌──────────────┐     enqueue     ┌─────────┐     dequeue     ┌──────────────┐
│  API Handler  │ ──────────────► │  Redis  │ ◄────────────── │    Worker    │
│  (producer)   │                 │ (broker) │                 │  (consumer)  │
└──────────────┘                 └─────────┘                 └──────────────┘
                                                                    │
                                                                    ▼
                                                            ┌──────────────┐
                                                            │  Task Handler │
                                                            │  (your code)  │
                                                            └──────────────┘
2

Challenge: Check Redis

Make sure Redis is running by checking your Docker containers:

Terminal
docker compose ps

What port does Redis use? (Hint: it's the default Redis port.)

Built-in Workers

Grit comes with 3 workers out of the box. You don't need to build them — they're already wired up and ready to process tasks the moment your app starts.

  • Email worker — Sends transactional emails via Resend. Handles welcome emails, password resets, email verification, and custom notifications. If Resend is down, the task retries automatically.
  • Image worker — Generates thumbnails after a file is uploaded. Resizes images to multiple dimensions, optimizes file size, and stores the results back in S3/MinIO.
  • Cleanup worker — Removes old or orphaned files. Runs on a schedule to purge expired uploads, temporary files, and data that's no longer referenced by any record.

All worker code lives in a single directory:

Project Structure
apps/api/internal/jobs/
├── email.go        # Email worker — sends emails via Resend
├── image.go        # Image worker — generates thumbnails
├── cleanup.go      # Cleanup worker — removes orphaned files
├── scheduler.go    # Cron scheduler — recurring tasks
└── worker.go       # Worker setup — registers all handlers
The worker.go file is where all task handlers are registered with the asynq mux. When you create a new custom job, this is where you'll add its handler registration.
3

Challenge: Explore the Workers

Open the apps/api/internal/jobs/ directory. List the files you see. For each file, describe in one sentence what that worker does based on the filename and any comments at the top.

Creating a Custom Job

Let's walk through creating a custom background job step by step. We'll build a report generation job — when a user requests a report, we'll queue it and generate it in the background.

Every asynq job has 4 parts: a task type (a string identifier), a payload (data the worker needs), a handler (the function that does the work), and a registration (telling the worker about the handler).

Step 1: Define the Task Type and Payload

The task type is a unique string that identifies this kind of job. The payload is a struct containing all the data the worker needs to process the task.

apps/api/internal/jobs/report.go
package jobs

const TypeReportGenerate = "report:generate"

type ReportPayload struct {
    UserID uint   `json:"user_id"`
    Type   string `json:"type"`
}

Step 2: Create the Handler

The handler is a function that receives the task, unmarshals the payload, and does the actual work. If it returns nil, the task is marked as completed. If it returns an error, the task is marked as failed and may be retried.

apps/api/internal/jobs/report.go
func HandleReportGenerate(ctx context.Context, t *asynq.Task) error {
    var p ReportPayload
    if err := json.Unmarshal(t.Payload(), &p); err != nil {
        return err
    }

    // Generate the report...
    log.Printf("Generating %s report for user %d", p.Type, p.UserID)

    // In a real app, you would:
    // 1. Query the database for the relevant data
    // 2. Format it into a PDF or CSV
    // 3. Upload the file to S3
    // 4. Notify the user that the report is ready

    return nil
}

Step 3: Register the Handler

In worker.go, register the handler so the worker knows which function to call when it sees a task with this type:

apps/api/internal/jobs/worker.go
mux.HandleFunc(TypeReportGenerate, HandleReportGenerate)

Step 4: Dispatch from a Handler or Service

Finally, enqueue the task from your API handler or service. Create the payload, wrap it in an asynq task, and enqueue it:

apps/api/internal/handlers/report_handler.go
payload, _ := json.Marshal(jobs.ReportPayload{
    UserID: 1,
    Type:   "monthly",
})
task := asynq.NewTask(jobs.TypeReportGenerate, payload)
info, err := client.Enqueue(task)
if err != nil {
    // Handle error
}
log.Printf("Task enqueued: id=%s queue=%s", info.ID, info.Queue)
Notice how the API handler doesn't generate the report itself. It just puts a message on the queue and responds to the user immediately. The worker does the heavy lifting later.
4

Challenge: Read the Email Worker

Read through the email worker code in apps/api/internal/jobs/email.go. Can you identify all 4 parts?

  1. What is the task type string?
  2. What fields are in the payload struct?
  3. What does the handler function do?
  4. Where is it registered?

Retry Logic

What happens when a background job fails? Maybe the email API is down, or the database is temporarily unavailable. You don't want to lose the task — you want to retry it.

asynq handles this automatically. By default, failed tasks are retried 3 times with exponential backoff. Each retry waits longer than the last, giving the failing service time to recover.

Exponential Backoff: A retry strategy where each attempt waits longer than the last: 1s, 2s, 4s, 8s, 16s, and so on. This prevents overwhelming a failing service with rapid retries. Instead of hammering a down server with requests every second, you gradually back off and give it time to recover.

You can configure retry behavior per task when you enqueue it:

Retry Configuration
// Retry up to 5 times (default is 3)
client.Enqueue(task, asynq.MaxRetry(5))

// Delay processing by 10 seconds
client.Enqueue(task, asynq.ProcessIn(10 * time.Second))

// Set a deadline — fail if not completed by this time
client.Enqueue(task, asynq.Deadline(time.Now().Add(1 * time.Hour)))

// Combine options
client.Enqueue(task,
    asynq.MaxRetry(5),
    asynq.ProcessIn(30 * time.Second),
    asynq.Queue("critical"),
)
Use asynq.Queue("critical") to put important tasks (like payment processing) on a separate queue with higher priority. Less important tasks (like cleanup) can go on a"low" priority queue.
5

Challenge: Understand Retry Behavior

What happens if a background job fails 3 times (the default max retry)? Where can you see failed jobs? (Hint: check the admin panel's System pages.)

Cron Scheduler

Some tasks need to run on a schedule — not triggered by a user action, but by the clock. Clean up expired sessions every night. Generate a daily report at 9am. Check for abandoned carts every hour. These are scheduled tasks.

Cron Expression: A string that defines a recurring schedule using 5 fields: minute, hour, day of month, month, and day of week. Originally from Unix, cron expressions are the universal standard for scheduling recurring tasks.
Scheduled Task: A background job that runs automatically on a recurring schedule, defined by a cron expression. Unlike regular jobs that are triggered by user actions, scheduled tasks are triggered by time.

Grit uses asynq's built-in scheduler. Here's the cron expression format:

Cron Expression Format
┌───────── minute (0-59)
│ ┌─────── hour (0-23)
│ │ ┌───── day of month (1-31)
│ │ │ ┌─── month (1-12)
│ │ │ │ ┌─ day of week (0-6, 0=Sunday)
│ │ │ │ │
* * * * *

The * means "every". Here are common patterns:

ExpressionMeaning
0 0 * * *Every day at midnight
*/5 * * * *Every 5 minutes
0 9 * * 1Every Monday at 9:00 AM
0 */2 * * *Every 2 hours
30 3 * * *Every day at 3:30 AM
0 0 1 * *First day of every month at midnight

Here's how Grit registers scheduled tasks in the scheduler:

apps/api/internal/jobs/scheduler.go
scheduler := asynq.NewScheduler(redisOpt, nil)

// Clean up orphaned files every day at 2am
scheduler.Register("0 2 * * *", asynq.NewTask(TypeCleanupFiles, nil))

// Generate daily stats at midnight
scheduler.Register("0 0 * * *", asynq.NewTask(TypeStatsDaily, nil))

// Check for expired sessions every hour
scheduler.Register("0 * * * *", asynq.NewTask(TypeSessionCleanup, nil))
6

Challenge: Write Cron Expressions

What cron expression would you use for each of these schedules?

  1. Every hour on the hour
  2. Every day at 3:30 PM
  3. Every Sunday at midnight

Admin Jobs Dashboard

Grit's admin panel includes a built-in Jobs dashboard where you can monitor all background tasks. No need for a separate monitoring tool — it's right there in your admin panel.

The Jobs page shows tasks grouped by status:

  • Active — tasks currently being processed by a worker
  • Pending — tasks waiting in the queue to be picked up
  • Completed — tasks that finished successfully
  • Failed — tasks that failed after all retries were exhausted
  • Scheduled — tasks delayed with ProcessIn, waiting for their time

For each failed task, you can see the error message, how many times it was retried, and when it last failed. You can also retry a failed task directly from the dashboard — no code changes needed.

The Jobs dashboard is one of the admin panel's System pages. You can access it from the sidebar under "System". It's useful for debugging issues in production — if emails aren't going out, check the Jobs page first.
7

Challenge: Explore the Jobs Dashboard

Visit the Jobs page in the admin panel at localhost:3001. Navigate to System > Jobs. Is there a queue? Are there any completed or failed jobs? Try triggering an action that creates a background job (like registering a new user) and watch the Jobs page update.

What is Transactional Email?

There are two kinds of email in web applications: marketing email and transactional email. They serve very different purposes.

Transactional Email: An email triggered by a specific user action or system event. Examples: password reset links, order confirmations, welcome emails after registration, account verification codes. These are one-to-one emails sent in response to something the user did — not bulk campaigns.

Here's how they differ:

TransactionalMarketing
Triggered by user actionSent in bulk to a list
One recipient at a timeHundreds or thousands at once
Expected by the userMay be unsolicited
Password resets, receiptsNewsletters, promotions
High priority, must arrive fastCan be delayed or batched

Grit uses Resend for transactional email. Resend is a modern email API designed for developers — simple, reliable, and with excellent deliverability. In local development, Grit uses Mailhog to catch all outgoing emails so you can preview them without sending real emails.

Email Configuration

Email configuration lives in your .env file. There are two variables you need:

.env
# Email — Resend
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxx
MAIL_FROM=noreply@yourapp.com

RESEND_API_KEY is your API key from resend.com.MAIL_FROM is the "from" address that appears on all outgoing emails.

For local development, you don't need a real Resend API key. Grit's Docker Compose includes Mailhog — a fake SMTP server that catches all outgoing emails and displays them in a web UI at localhost:8025.

docker-compose.yml (Mailhog section)
mailhog:
  image: mailhog/mailhog
  ports:
    - "1025:1025"   # SMTP port — your app sends here
    - "8025:8025"   # Web UI — view caught emails here
Mailhog catches every email your app sends during development. This means you can test password reset flows, welcome emails, and notifications without worrying about accidentally emailing real users.
8

Challenge: Test Email with Mailhog

Open localhost:8025 in your browser (Mailhog). Then register a new user in the web app at localhost:3000. Go back to Mailhog — did a welcome email arrive? Open it and inspect the HTML content.

Email Templates

Grit includes 4 pre-built HTML email templates that cover the most common transactional email needs. They're clean, responsive, and ready to customize.

  • welcome — sent when a user registers a new account
  • password-reset — sent when a user requests a password reset link
  • verify-email — sent to confirm a user's email address
  • notification — a generic template for custom notifications

All templates live in the apps/api/internal/mail/ directory:

Project Structure
apps/api/internal/mail/
├── mailer.go             # Send function, SMTP config
├── templates/
│   ├── welcome.html      # Welcome email template
│   ├── password-reset.html
│   ├── verify-email.html
│   └── notification.html

Templates use Go's built-in html/template package. Dynamic data is injected using double curly braces:

apps/api/internal/mail/templates/welcome.html
<!DOCTYPE html>
<html>
<head>
    <style>
        body { font-family: sans-serif; background: #f4f4f4; padding: 20px; }
        .container { max-width: 600px; margin: 0 auto; background: #fff; padding: 32px; border-radius: 8px; }
        .button { background: #6c5ce7; color: #fff; padding: 12px 24px; text-decoration: none; border-radius: 6px; }
    </style>
</head>
<body>
    <div class="container">
        <h1>Welcome, {{.Name}}!</h1>
        <p>Thanks for joining {{.AppName}}. We're excited to have you.</p>
        <p><a href="{{.DashboardURL}}" class="button">Go to Dashboard</a></p>
    </div>
</body>
</html>

The {{.Name}} syntax is Go's template language. When you send the email, you pass a map of data, and Go replaces each {{.Key}} with the corresponding value.

9

Challenge: Inspect a Template

Open the welcome email template file. Find the placeholder for the user's name. How does Go inject the user's actual name? What other placeholders does the template use?

Sending Email from Code

Sending an email from your Go code is straightforward. Grit provides a mailer package with a Send function that handles template rendering and delivery.

Sending an Email
err := mailer.Send(mail.Email{
    To:       "user@example.com",
    Subject:  "Welcome!",
    Template: "welcome",
    Data: map[string]string{
        "Name":         "John",
        "AppName":      "My App",
        "DashboardURL": "https://myapp.com/dashboard",
    },
})

The Template field matches one of the HTML files in the templates/ directory (without the .html extension). The Data map provides the values for all the {{.Key}} placeholders in the template.

But remember — you should never send emails directly from an API handler. Instead, dispatch a background job that sends the email. This way the user's request isn't blocked by the email service:

The Right Way — Dispatch as a Background Job
// In your handler or service
payload, _ := json.Marshal(jobs.EmailPayload{
    To:       "user@example.com",
    Subject:  "Welcome!",
    Template: "welcome",
    Data:     map[string]string{"Name": "John"},
})
task := asynq.NewTask(jobs.TypeEmailSend, payload)
client.Enqueue(task)

// The email worker will pick this up and call mailer.Send()
Always send emails through the background job queue, not inline. This gives you automatic retries if the email service is down, and keeps your API responses fast.
10

Challenge: Find the Welcome Email Dispatch

Look at the auth handler registration code in apps/api/internal/handlers/. Can you find where the welcome email is dispatched as a background job after a user registers? What task type does it use?

11

Challenge: Send a Test Email

Using the mailer package, write the Go code to send a password reset email to test@example.com with the user's name set to "Alice" and a reset link of https://myapp.com/reset?token=abc123. Which template would you use?

Summary

In this course, you learned how to offload slow or unreliable tasks to background workers using asynq and Redis, and how to send transactional emails with Go templates and Resend. Here's what you covered:

  • Background jobs — tasks that run asynchronously outside the user's request, keeping the API fast
  • asynq architecture — producers enqueue tasks to Redis, workers dequeue and process them
  • Built-in workers — email, image processing, and cleanup workers come pre-configured
  • Custom jobs — 4 steps: define task type, create handler, register it, dispatch from your code
  • Retry logic — exponential backoff with configurable max retries and delayed processing
  • Cron scheduler — recurring tasks on a schedule using cron expressions
  • Admin Jobs dashboard — monitor active, pending, completed, and failed tasks from the admin panel
  • Transactional email — user-triggered emails using Resend, with Go HTML templates and Mailhog for local dev
12

Challenge: Final Challenge: Create a Weekly Digest

Design a background job system that sends a weekly digest email to all users. Think through every piece you would need:

  1. Task type — What would you name it? (e.g., digest:weekly)
  2. Payload struct — What data does the worker need? (Think: does it need a user ID, or does it query all users?)
  3. Handler function — Write pseudocode: query new posts from the past 7 days, loop through all users, send each one a digest email
  4. Cron expression — What expression runs every Monday at 8:00 AM?
  5. Email template — What placeholders would it need? (Name, post titles, post links, date range)

Write out the full task type constant, payload struct, handler skeleton, cron registration line, and a list of template placeholders.

13

Challenge: Bonus: Error Handling Strategy

Think about what could go wrong with the weekly digest job. What if the database query fails halfway through? What if Resend is down for one user but works for others? Should you send all emails in one task, or enqueue a separate "send digest" task per user? What are the tradeoffs?

14

Challenge: Bonus: Monitoring Checklist

Create a monitoring checklist for a production app using background jobs. What would you check daily? Consider: queue depth (are jobs piling up?), failure rate, retry counts, worker health, Redis memory usage, and email delivery rates. How would the admin Jobs dashboard help you catch problems early?