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.
- The sign-up handler creates the user and queues a
send-welcome-emailjob on theemailsqueue. - Halyard stores the job durably and answers in a few milliseconds.
- A worker you run picks up the job, loads the user and calls your mailer.
- If the mailer throws, Halyard retries with exponential backoff until the job succeeds or runs out of attempts.
Build it
Install the SDK and set your key
npm install @halyard/nodepip install halyardPut a test key in your environment. Test keys run against a sandbox, so nothing here sends a real email unless your mailer does.
.env.localHALYARD_KEY=hy_test_51Hq8Zx2rTmV9cKpL3dQueue 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 });}# app/signup.pyimport osfrom halyard import Halyardfrom app.users import create_user halyard = Halyard(os.environ["HALYARD_KEY"]) def signup(email: str, name: str) -> dict: user = create_user(email=email, name=name) halyard.jobs.create( queue="emails", task="send-welcome-email", payload={"user_id": user.id}, idempotency_key=f"welcome:{user.id}", ) return {"id": user.id}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();# worker.pyimport osfrom halyard import Worker, NonRetryableErrorfrom app.users import get_userfrom app.mailer import send_email worker = Worker(os.environ["HALYARD_KEY"], queues=["emails"]) @worker.task("send-welcome-email")def send_welcome_email(job): user = get_user(job.payload["user_id"]) if user is None: raise NonRetryableError("User no longer exists") send_email( to=user.email, template="welcome", data={"first_name": user.name.split(" ")[0]}, ) job.log(f"Welcome email sent to {user.email}") worker.start()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,});halyard.jobs.create( queue="emails", task="send-welcome-email", payload={"user_id": user.id}, idempotency_key=f"welcome:{user.id}", max_attempts=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.