When you queue a background job, you need to know when it finishes. Two fundamental approaches exist: keep asking until you get an answer, or let the system notify you on completion. The first approach is polling. The second is signal-based completion.
Picking the wrong pattern can mean wasted API calls, added latency, or over-engineered infrastructure. This guide breaks down both approaches and helps you choose.
Step 1: Understand Polling-Based Completion
Polling means your application checks the status of a task at fixed intervals until it reaches a terminal state.
const { task } = await aq.tasks.create({ targetUrl: 'https://your-app.com/api/convert-video', payload: { videoId: 'abc123' },});
// Poll every 5 secondsconst interval = setInterval(async () => { const result = await aq.tasks.get(task.id); if (['completed', 'failed', 'timeout'].includes(result.status)) { clearInterval(interval); console.log('Task finished:', result.status); }}, 5000);When polling works well:
- Simple scripts or CLI tools that run once
- Tasks that finish in under 30 seconds
- Situations where you already have a loop running (e.g., a UI refresh cycle)
Downsides:
- Wasted requests when the task takes a long time
- Detection lag between completion and discovery (up to one poll interval)
- Higher API usage and rate limit consumption
Step 2: Understand Signal-Based Completion
Signal-based completion flips the model. Instead of asking “are you done yet?”, you tell the system: “notify me when you finish.”
With wait-for-signal, you create a task that pauses after its initial callback. An external system, a person, or another service then sends a signal to resolve the task.
const { task, signalToken } = await aq.tasks.create({ targetUrl: 'https://your-app.com/api/start-review', payload: { documentId: 'doc_456' }, waitForSignal: true, maxWaitTime: 86400, // 24 hours});
// Store signalToken - you will use it laterawait db.save({ documentId: 'doc_456', signalToken });When the review is approved (hours or days later):
await fetch('https://api.asyncqueue.io/v1/signals/' + signalToken, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'completed', }),});When signal-based works well:
- Tasks that depend on external events (payments, approvals, third-party callbacks)
- Long-running processes where the completion time is unpredictable
- Workflows that involve human decisions
Step 3: Compare Latency, Cost, and Complexity
| Factor | Polling | Signal-Based |
|---|---|---|
| Latency to detect completion | Up to one poll interval | Instant (signal triggers it) |
| API calls | Many (proportional to wait time) | One (the signal itself) |
| Rate limit impact | High for long tasks | Minimal |
| Implementation complexity | Low (just a loop) | Moderate (must store and forward token) |
| Works for human-in-the-loop | Poorly (could poll for hours) | Naturally (signal sent when human acts) |
| Works for sub-second tasks | Well (one or two polls) | Overkill |
Step 4: Choose the Right Approach
Use polling when:
- The task finishes in under 30 seconds
- You are building a throwaway script, not a production system
- You lack a webhook endpoint to receive signals
Use signal-based when:
- The task depends on an external event you cannot predict
- A person must take action before the task can finish
- You want to minimize API calls and stay under rate limits
- The wait time could span minutes, hours, or days
Use onComplete webhooks when:
- You want push notification on completion but do not need to control the resolution
- The task finishes on its own (no external dependency)
- You prefer push-style notification over periodic checks
// onComplete webhook - no signal needed, just notificationawait aq.tasks.create({ targetUrl: 'https://your-app.com/api/process', onCompleteUrl: 'https://your-app.com/api/webhook',});Step 5: Implement Signal-Based Completion
Here is the full pattern for a payment confirmation workflow:
// 1. Create a task that waits for payment confirmationconst { task, signalToken } = await aq.tasks.create({ targetUrl: 'https://your-app.com/api/start-checkout', payload: { orderId: 'order_789', amount: 99.99 }, waitForSignal: true, maxWaitTime: 3600, // 1 hour to complete payment onCompleteUrl: 'https://your-app.com/api/order-webhook',});
// 2. Store the signal token with the orderawait db.orders.update('order_789', { signalToken });
// 3. When Stripe sends a payment_intent.succeeded webhook:app.post('/api/stripe-webhook', async (req, res) => { const order = await db.orders.findByPaymentIntent(req.body.payment_intent);
await fetch('https://api.asyncqueue.io/v1/signals/' + order.signalToken, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'completed', result: { paymentId: req.body.payment_intent }, }), });
res.json({ received: true });});If the customer never pays within maxWaitTime, the task transitions to timeout status and the onCompleteUrl webhook fires with the timeout event.