Moving multi-step automation from BullMQ to Temporal
Why job queues stopped fitting multi-day automation sequences, where to draw workflow and activity boundaries, determinism rules and a safe migration.
6 min readtemporal · bullmq · redis · workflows · typescript
BullMQ is a good queue. If I need background jobs in a Node.js service and Redis is already around, I'd still reach for it. Automation on Sendler.ai's AI outreach platform started on BullMQ and Redis queues and scaled to 1M+ actions per day, and I then led the migration to Temporal for durable, retryable and observable workflows.
This post is about why that move made sense for this kind of work, and how to make it without freezing the product.
When a job is really a sequence
The platform does AI LinkedIn outreach and sales automation, and an outreach automation is not one job. A simplified sequence looks like this:
- Send a connection request.
- Wait up to a couple of weeks for it to be accepted.
- Generate a personalised message and send it.
- Wait a few days. If the lead replied, stop.
- Send a follow-up.
On a queue, each step is a job, each wait is a delayed job, and the answer to "where is this lead in the sequence?" is spread across Redis keys, delayed sets and database flags. Each of these needs its own code:
- Cancellation. A reply has to find and remove pending follow-ups, and race the worker that might be picking one up at that moment.
- Retries. They work per job, so resuming a sequence after a failed step is custom logic.
- Observability. Explaining why one lead didn't get a follow-up means stitching together logs from several jobs.
- Change. Editing a sequence while leads are halfway through it means reasoning about every delayed job already scheduled.
None of this is impossible with a queue. The cost is that you solve it again for every new kind of sequence, and the solution ends up scattered across handlers nobody can read in one place.
What Temporal changes
In Temporal, the sequence is a function. Waiting two weeks is a condition with a timeout, and it's durable: workers can restart, deploy or crash, and the workflow resumes from its event history. A reply is a signal. Retries are a policy on each activity. Every execution has a history you can open and read step by step, which answers the support question directly.
import { condition, defineSignal, proxyActivities, setHandler, sleep } from '@temporalio/workflow';
import type * as activities from './activities';
const { reserveSlot, sendConnectionRequest, generateMessage, sendMessage } =
proxyActivities<typeof activities>({
startToCloseTimeout: '1 minute',
retry: { initialInterval: '10s', backoffCoefficient: 2, maximumAttempts: 5 },
});
export const accepted = defineSignal('accepted');
export const replied = defineSignal('replied');
type Input = { accountId: string; leadId: string; campaignId: string };
export async function outreach(input: Input): Promise<string> {
let isAccepted = false;
let hasReplied = false;
setHandler(accepted, () => {
isAccepted = true;
});
setHandler(replied, () => {
hasReplied = true;
});
await waitForSlot(input.accountId);
await sendConnectionRequest(input);
if (!(await condition(() => isAccepted, '14 days'))) return 'not-accepted';
for (const step of ['intro', 'follow-up'] as const) {
if (hasReplied) return 'replied';
await waitForSlot(input.accountId);
const text = await generateMessage({ ...input, step });
await sendMessage({ ...input, step, text });
if (await condition(() => hasReplied, '3 days')) return 'replied';
}
return 'completed';
}
// Per-account limits live outside Temporal; the activity says how long to wait.
async function waitForSlot(accountId: string): Promise<void> {
let waitMs = await reserveSlot(accountId);
while (waitMs > 0) {
await sleep(waitMs);
waitMs = await reserveSlot(accountId);
}
}Workflows decide, activities do
The boundary I use: workflow code makes decisions and waits; activities do anything that touches the outside world. Network calls, database reads and writes, LLM calls and anything that depends on the current time or randomness all belong in activities.
Within that rule:
- One side effect per activity. If an activity sends a message and then updates a CRM, a retry after the second step fails sends the message again.
- Make activities idempotent anyway, keyed by workflow ID and step. An activity can finish its work and still be retried if the worker dies before reporting success.
- Pass IDs, not documents. Workflow inputs and activity results are stored in the event history, which has size limits. A lead ID is better than a lead profile.
- Mark permanent failures as non-retryable, so an account the platform can't act for doesn't burn five attempts on every step.
Rate limits deserve a note. Temporal can limit activity throughput per task queue, but per-account limits are a tenant-level rule it doesn't model for you. Keeping them in the limiter the product already owns, checked through an activity like reserveSlot above, means the workflow simply sleeps until the account has capacity.
Determinism rules
Temporal rebuilds workflow state by replaying workflow code against its history, so that code must make the same decisions every time it runs:
- No I/O in workflow code. The TypeScript SDK bundles workflows separately and rejects Node.js built-ins, which catches many mistakes early.
- Use the SDK for time.
sleepandconditiontimeouts become durable timers in the history. The sandbox also makesDateandMath.randomdeterministic, but treat that as a safety net, not something to design around. - Don't read mutable config inside a workflow. Pass it in as input or fetch it through an activity, so the value is recorded in history.
- Version changes to running code. Sequences last days, so new code meets old executions. Use
patched()for changes that alter the order of commands, and run replay tests against recorded histories in CI before deploying workflow changes.
The last rule is the one teams new to Temporal underestimate. A deploy is no longer only "new requests get new code"; it's "every running workflow replays its past on the new code".
Migrating without a freeze
The strategy that keeps the risk low:
- Reuse the handlers. The cheapest path is to wrap existing job handlers as activities, so the code that talks to the outside world barely changes. What's new is the orchestration.
- Start new, drain old. New sequences start on Temporal behind a per-account flag, and sequences already running on BullMQ finish there. Moving in-flight state between systems is the riskiest part of any migration, and when sequences end within weeks you can usually avoid it entirely.
- Share idempotency keys. Both systems use the same key scheme, so an overlap or a mistaken double start can't send the same message twice.
- Run both side by side and compare completion rates, failure rates and step latency per account before widening the flag.
- Delete the old path once it drains. A half-migrated system with two orchestration models is harder to operate than either one alone.
The signal that you've outgrown a queue isn't throughput. It's when the state that matters is "where is this entity in a multi-step process", and you notice you're hand-building cancellation, resumption and history for every new sequence. At that point, move orchestration into workflows, keep side effects in small idempotent activities, keep tenant-level limits in your own limiter, and migrate by starting new work on the new system while the old one drains.