Skip to content
EmailsBeginner8 min

Send welcome emails in the background

Queue an email job at sign-up, send it from a worker and let Halyard retry when your email provider has a bad minute.

Mara IlvesDeveloper advocate · Updated October 9, 2026
  • Node.js
  • Python
On this page

How it works

Sending an email inside the sign-up request ties your response time to your email provider's. On a good day that adds 300 ms; on a bad day the request times out and the user sees an error for something that already worked. With Halyard the handler does one thing, queues a job, and returns.

  1. The sign-up handler creates the user and queues a send-welcome-email job on the emails queue.
  2. Halyard stores the job durably and answers in a few milliseconds.
  3. A worker you run picks up the job, loads the user and calls your mailer.
  4. If the mailer throws, Halyard retries with exponential backoff until the job succeeds or runs out of attempts.

Build it

  1. Install the SDK and set your key

    npm install @halyard/node

    Put a test key in your environment. Test keys run against a sandbox, so nothing here sends a real email unless your mailer does.

    .env.local
    HALYARD_KEY=hy_test_51Hq8Zx2rTmV9cKpL3d
  2. Queue the job when a user signs up

    Create the job right after the user is saved. The payload holds only the user's ID: the worker reads the current name and address when it runs, and no personal data sits in the queue.

    // app/api/signup/route.jsimport { Halyard } from "@halyard/node";import { createUser } from "@/lib/users"; const halyard = new Halyard(process.env.HALYARD_KEY); export async function POST(request) {  const { email, name } = await request.json();  const user = await createUser({ email, name });   await halyard.jobs.create({    queue: "emails",    task: "send-welcome-email",    payload: { user_id: user.id },    idempotencyKey: `welcome:${user.id}`,  });   return Response.json({ id: user.id }, { status: 201 });}
  3. Write the worker

    A worker is a long-running process that listens on one or more queues. Register a handler per task name; whatever the handler returns is stored as the job's result, and anything it throws counts as a failed attempt.

    // worker.jsimport { Worker, NonRetryableError } from "@halyard/node";import { getUser } from "./lib/users.js";import { sendEmail } from "./lib/mailer.js"; const worker = new Worker(process.env.HALYARD_KEY, { queues: ["emails"] }); worker.task("send-welcome-email", async (job) => {  const user = await getUser(job.payload.user_id);  if (!user) throw new NonRetryableError("User no longer exists");   await sendEmail({    to: user.email,    template: "welcome",    data: { firstName: user.name.split(" ")[0] },  });   await job.log(`Welcome email sent to ${user.email}`);}); worker.start();
  4. Run it locally

    Start the worker, then sign up a test user in your app. Each attempt shows up in the worker's output and in the dashboard.

    Terminal
    $ node worker.jshalyard  worker wkr_5Tn2Qa started · queues: emails · concurrency 10halyard  job_3Rk9Tq7Xw2 send-welcome-email  running   attempt 1/5halyard  job_3Rk9Tq7Xw2 send-welcome-email  log       Welcome email sent to ada@lumen.examplehalyard  job_3Rk9Tq7Xw2 send-welcome-email  succeeded 412 ms

When the email provider is down

Every job gets five attempts by default. The delay doubles each time, starting at 10 seconds and with up to 20% random jitter, so a provider hiccup of a couple of minutes is absorbed without anyone noticing. Welcome emails are worth more patience than that: eight attempts keep retrying for about 20 minutes.

await halyard.jobs.create({  queue: "emails",  task: "send-welcome-email",  payload: { user_id: user.id },  idempotencyKey: `welcome:${user.id}`,  maxAttempts: 8,});

Jobs that use every attempt move to the queue's dead-letter list, where you can inspect the last error and retry them in bulk once the provider is back. See Retries and backoff for the full schedule.

Double clicks and duplicates

Forms get submitted twice and load balancers retry requests. The idempotencyKey makes that harmless: for 24 hours, a second jobs.create with the same key returns the existing job instead of creating a new one, so the user gets exactly one welcome email.

Recap

  • The sign-up handler only queues a job; the user never waits on your email provider.
  • Payloads carry an ID, not personal data, so the worker always reads fresh user details.
  • An idempotency key of welcome:<user id> makes double submits harmless.
  • Thrown errors retry with backoff; NonRetryableError stops retries for problems that won't fix themselves.

Next steps

Keep going with a related guide, or look up every option used here in the Jobs API reference.

Suggested