logo

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 seconds
const 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 later
await 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',
result: { approved: true, reviewer: '[email protected]' },
}),
});

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

FactorPollingSignal-Based
Latency to detect completionUp to one poll intervalInstant (signal triggers it)
API callsMany (proportional to wait time)One (the signal itself)
Rate limit impactHigh for long tasksMinimal
Implementation complexityLow (just a loop)Moderate (must store and forward token)
Works for human-in-the-loopPoorly (could poll for hours)Naturally (signal sent when human acts)
Works for sub-second tasksWell (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 notification
await 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 confirmation
const { 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 order
await 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.