Notification Workers
Workers are where notification intent becomes provider calls. They are also where many production bugs live: duplicate sends, missing variables, infinite retries, unbounded timeouts, and confusing status updates.
Worker Lifecycle
Take Job
|
v
Validate
|
v
Load Template
|
v
Replace Variables
|
v
Call Provider
|
v
Update Status
|
v
Success
Every box can fail. A strong worker treats failure as expected, not exceptional.
Job Payload
type SendNotificationJob = {
notificationId: string;
channel: "email" | "sms" | "push" | "in_app";
attempt: number;
};
Keep job payloads small. Store durable state in the database. If the queue message contains the full rendered email, stale data and replay behavior become harder to reason about.
Worker Implementation
async function processNotificationJob(job: SendNotificationJob) {
const notification = await db.notification.findUniqueOrThrow({
where: { id: job.notificationId }
});
if (notification.status === "sent") return;
const template = await loadActiveTemplate(
notification.templateKey,
job.channel
);
const rendered = renderTemplate(template.body, notification.variables);
validateRenderedMessage(rendered);
const attempt = await createDeliveryAttempt(notification.id, job.channel);
try {
const response = await providerFor(job.channel).send(rendered, {
timeoutMs: 10_000
});
await markAttemptSuccess(attempt.id, response.messageId);
await maybeMarkNotificationSent(notification.id);
} catch (error) {
await markAttemptFailed(attempt.id, error);
throw error;
}
}
Idempotency matters. If the worker crashes after the provider accepts a message but before the database update, the queue may retry. Provider idempotency keys can reduce duplicate sends.
Provider Idempotency
const idempotencyKey = `${notification.id}:${job.channel}`;
await emailProvider.send({
to: recipient.email,
subject,
html,
headers: {
"Idempotency-Key": idempotencyKey
}
});
Not all providers support this. If they do not, store provider message IDs and make your duplicate detection as strong as possible.
Timeouts
Never let provider calls hang forever.
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 10_000);
try {
await fetch(providerUrl, {
method: "POST",
body: JSON.stringify(payload),
signal: controller.signal
});
} finally {
clearTimeout(timer);
}
Timeouts protect worker capacity. Without them, a provider issue can freeze the whole pool.
Poison Messages
A poison message fails every time because the data is bad. Example: template requires firstName, but the notification variables do not contain it.
Missing variable -> retry -> missing variable -> retry -> DLQ
The fix is not more retries. The fix is validation before enqueue or before provider call.
Retries are for temporary failures. Validation errors should fail fast and become visible.
Common Mistakes
- Putting full business data in queue messages.
- Not setting provider timeouts.
- Updating final status before provider acceptance.
- Retrying validation failures.
- Running one giant worker for every channel and priority.
Interview Questions
- Why should worker jobs be small?
- How can a duplicate send happen after a worker crash?
- What is a poison message?
- Why should provider calls have timeouts?
Exercise
Write worker pseudo-code for a push notification job. Include token validation, provider call, retryable failure, and final status update.
What you will learn
The lifecycle of a notification worker.
How to validate jobs before provider calls.
How to update delivery status safely.
How timeouts, retries, and poison messages are handled.
Production checklist
- Worker validates job payload
- Template variables are checked
- Provider timeout exists
- Status updates are transactional
- Poison messages go to DLQ
- Worker is idempotent