logo

Every async task falls into one of two categories: tasks you fire and move on from, and tasks that must wait for something external before reaching completion. Choosing the wrong pattern leads to over-engineering simple workflows or under-engineering complex ones.

Step 1: Understand Fire-and-Forget

Fire-and-forget is the simplest async pattern. You create a task, the queue executes the callback, and the result gets stored. Your application never blocks on the outcome.

// Send a welcome email - you don't need to wait for delivery
await aq.tasks.create({
targetUrl: 'https://your-app.com/api/send-welcome-email',
payload: { userId: 'user_123', email: '[email protected]' },
maxRetries: 3,
});
// Your code continues immediately
console.log('Task queued, moving on');

Characteristics:

  • Task completes on its own after the callback succeeds
  • No external dependency controls the outcome
  • You might use onCompleteUrl to get notified, but you never block on the response
  • The callback response serves as the final result

Good for:

  • Sending emails and notifications
  • Logging and analytics events
  • Cache warming and precomputation
  • Cleanup and maintenance jobs
  • Any task where the callback handles the entire job

Step 2: Understand Wait-for-Signal

Wait-for-signal tasks pause after their initial callback and hold for an external event to resolve. The task stays in a waiting state until a signal arrives or the timeout expires.

// Start a payment checkout - need to wait for customer to pay
const { task, signalToken } = await aq.tasks.create({
targetUrl: 'https://your-app.com/api/create-checkout',
payload: { orderId: 'order_456', amount: 149.99 },
waitForSignal: true,
maxWaitTime: 3600,
});
// Store the token - you'll need it when the payment confirms
await db.orders.update('order_456', { signalToken, taskId: task.id });

Later, when the payment processor sends a webhook:

app.post('/api/payment-webhook', async (req, res) => {
const order = await db.orders.findByPaymentId(req.body.paymentId);
// Signal the waiting task
await fetch(`https://api.asyncqueue.io/v1/signals/${order.signalToken}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
status: 'completed',
result: { transactionId: req.body.transactionId },
}),
});
res.json({ received: true });
});

Characteristics:

  • The callback starts the process, but an external actor finishes the job
  • A signal token authenticates the completion (no API key needed)
  • The task can wait seconds, hours, or days
  • If no signal arrives, the task times out on schedule
  • Both the callback response (startResult) and the signal data (result) get captured

Good for:

  • Payment confirmations
  • Human approvals and reviews
  • Third-party processing that calls back when finished
  • Multi-step workflows with external dependencies
  • Any task where completion depends on an event outside your control

Step 3: Compare the Two Patterns

AspectFire-and-ForgetWait-for-Signal
CompletionAutomatic after callbackManual via signal
External dependencyNoneYes
Signal tokenNot issuedIssued and returned
Task statespending -> processing -> completedpending -> processing -> waiting -> completed
Timeout behaviorHTTP timeout onlyHTTP timeout + wait timeout
Result dataCallback response onlyCallback response + signal data
ComplexityLowModerate
Typical durationSeconds to minutesMinutes to days

Step 4: Decide Based on Your Workflow

Ask yourself one question: Does something outside your callback need to happen before the task is “done”?

  • No -> Fire-and-forget
  • Yes -> Wait-for-signal

Here are common scenarios mapped to their patterns:

ScenarioPatternWhy
Send an emailFire-and-forgetEmail API responds at once; delivery happens in the background
Process a paymentWait-for-signalCustomer action required, payment processor calls back
Generate a PDFFire-and-forgetYour PDF service returns the file in the callback
Convert a video via third-partyWait-for-signalThird-party calls back when conversion finishes
Run a database migrationFire-and-forgetThe migration runs and completes in your callback
Get manager approvalWait-for-signalHuman action required
Sync data to a warehouseFire-and-forgetYour sync endpoint handles the entire job
Place a shipping orderWait-for-signalCarrier confirms pickup via webhook
Clean up expired sessionsFire-and-forgetPure cleanup with no external dependency
Verify identity via KYC providerWait-for-signalProvider calls back with verification results

Step 5: Combine Both Patterns in a Single Workflow

Real workflows often mix both patterns. An e-commerce order might look like this:

// Step 1: Fire-and-forget - validate inventory
await aq.tasks.create({
targetUrl: 'https://your-app.com/api/check-inventory',
payload: { orderId, items },
onCompleteUrl: 'https://your-app.com/api/order-pipeline',
});
// Step 2 (triggered by onComplete webhook): Wait-for-signal - charge payment
app.post('/api/order-pipeline', async (req, res) => {
if (req.body.task.status !== 'completed') {
await cancelOrder(req.body.task.payload.orderId);
return res.json({ received: true });
}
const { task, signalToken } = await aq.tasks.create({
targetUrl: 'https://your-app.com/api/charge-payment',
payload: { orderId, amount: 149.99 },
waitForSignal: true,
maxWaitTime: 3600,
onCompleteUrl: 'https://your-app.com/api/order-pipeline-step3',
});
await db.orders.update(orderId, { paymentSignalToken: signalToken });
res.json({ received: true });
});
// Step 3 (triggered after payment signal): Fire-and-forget - send confirmation
app.post('/api/order-pipeline-step3', async (req, res) => {
if (req.body.task.status === 'completed') {
await aq.tasks.create({
targetUrl: 'https://your-app.com/api/send-order-confirmation',
payload: { orderId, paymentResult: req.body.task.result },
});
} else {
await handlePaymentFailure(orderId, req.body.task.status);
}
res.json({ received: true });
});

This pipeline chains three tasks: inventory check (fire-and-forget), payment (wait-for-signal), and confirmation email (fire-and-forget). Each step uses the pattern that matches its requirements.