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 deliveryawait aq.tasks.create({ targetUrl: 'https://your-app.com/api/send-welcome-email', maxRetries: 3,});
// Your code continues immediatelyconsole.log('Task queued, moving on');Characteristics:
- Task completes on its own after the callback succeeds
- No external dependency controls the outcome
- You might use
onCompleteUrlto 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 payconst { 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 confirmsawait 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
| Aspect | Fire-and-Forget | Wait-for-Signal |
|---|---|---|
| Completion | Automatic after callback | Manual via signal |
| External dependency | None | Yes |
| Signal token | Not issued | Issued and returned |
| Task states | pending -> processing -> completed | pending -> processing -> waiting -> completed |
| Timeout behavior | HTTP timeout only | HTTP timeout + wait timeout |
| Result data | Callback response only | Callback response + signal data |
| Complexity | Low | Moderate |
| Typical duration | Seconds to minutes | Minutes 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:
| Scenario | Pattern | Why |
|---|---|---|
| Send an email | Fire-and-forget | Email API responds at once; delivery happens in the background |
| Process a payment | Wait-for-signal | Customer action required, payment processor calls back |
| Generate a PDF | Fire-and-forget | Your PDF service returns the file in the callback |
| Convert a video via third-party | Wait-for-signal | Third-party calls back when conversion finishes |
| Run a database migration | Fire-and-forget | The migration runs and completes in your callback |
| Get manager approval | Wait-for-signal | Human action required |
| Sync data to a warehouse | Fire-and-forget | Your sync endpoint handles the entire job |
| Place a shipping order | Wait-for-signal | Carrier confirms pickup via webhook |
| Clean up expired sessions | Fire-and-forget | Pure cleanup with no external dependency |
| Verify identity via KYC provider | Wait-for-signal | Provider 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 inventoryawait 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 paymentapp.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 confirmationapp.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.