How API Orchestration Works
API orchestration coordinates multiple API calls into a single, managed workflow. Instead of scattering service calls across your codebase and hoping they all succeed, an orchestrator controls the sequence, passes data between steps, handles failures, and tracks progress from one central point.
If you have ever written a 200-line controller calling five services in a row with nested try/catch blocks, you have already built ad-hoc orchestration. This guide shows how to replace that mess with a proper task queue.
Prerequisites: Familiarity with task queues, webhooks, and callbacks. See How to Chain Multiple API Calls for the simpler sequential pattern.
Step 1: Understand What API Orchestration Is
API orchestration differs from simple chaining in both scope and control:
| API Chaining | API Orchestration | |
|---|---|---|
| Control flow | Linear, step-by-step | Branching, parallel, conditional |
| Error handling | Retry or fail | Compensate, skip, or route to fallback |
| State management | Passed between steps | Centralized workflow state |
| Visibility | Per-task logs | Full workflow timeline |
| Complexity | Low | Medium to high |
Choose orchestration when your workflow has any of these characteristics:
- Steps that can run in parallel
- Conditional branching (if payment fails, try a different provider)
- Compensation logic (if shipping fails, refund the payment)
- Multiple services that need a consistent view of workflow state
Step 2: Choose Your Orchestration Pattern
Sequential
Each step depends on the previous result. Best for simple pipelines.
Validate Order -> Charge Payment -> Create Shipment -> Send EmailParallel (Fan-Out/Fan-In)
Independent steps run concurrently. Results are aggregated once every branch finishes.
┌─ Fetch User Profile ──┐Start Workflow ──┼─ Fetch Order History ──┼── Merge & Respond └─ Fetch Preferences ───┘Hybrid
Blends sequential and parallel execution. Most real-world workflows follow this pattern.
Validate Order ─┬─ Charge Payment ────┬─ Send Confirmation └─ Reserve Inventory ─┘Step 3: Build an Orchestrated Workflow with AsyncQueue
Here is a hybrid workflow for processing a new subscription. First, validate the user. Then charge payment and provision the account in parallel. Finally, send a welcome email.
Define the workflow
const WORKFLOW_STEPS = { validate: { targetUrl: 'https://your-app.com/api/steps/validate-user', next: ['charge', 'provision'], // fan-out after validation retries: 1, timeout: 10, }, charge: { targetUrl: 'https://your-app.com/api/steps/charge-payment', next: ['welcome'], // converge before welcome email retries: 2, timeout: 30, }, provision: { targetUrl: 'https://your-app.com/api/steps/provision-account', next: ['welcome'], retries: 3, timeout: 20, }, welcome: { targetUrl: 'https://your-app.com/api/steps/send-welcome', next: [], waitFor: ['charge', 'provision'], // fan-in: wait for both retries: 3, timeout: 15, },};Start the workflow
app.post('/api/subscribe', async (req, res) => { const workflowId = crypto.randomUUID();
await saveWorkflow({ id: workflowId, type: 'subscription', status: 'running', completedSteps: [], results: {}, context: req.body, });
// Kick off the first step await dispatchStep(workflowId, 'validate', req.body);
res.json({ workflowId, status: 'processing' });});Central dispatch function
async function dispatchStep(workflowId, stepName, payload) { const step = WORKFLOW_STEPS[stepName];
await aq.tasks.create({ targetUrl: step.targetUrl, payload: { workflowId, step: stepName, ...payload }, webhookUrl: 'https://your-app.com/api/orchestrator', retries: step.retries, timeout: step.timeout, });
await updateWorkflow(workflowId, { [`steps.${stepName}`]: 'running', });}Central orchestrator endpoint
This is the core of the pattern. A single webhook endpoint receives every step’s result and decides what happens next:
app.post('/api/orchestrator', async (req, res) => { const { workflowId, step: completedStep } = req.body.payload; const result = req.body.result;
const workflow = await getWorkflow(workflowId);
// Store the result and mark the step complete workflow.completedSteps.push(completedStep); workflow.results[completedStep] = result; await updateWorkflow(workflowId, { [`steps.${completedStep}`]: 'completed', completedSteps: workflow.completedSteps, [`results.${completedStep}`]: result, });
// Determine which steps to dispatch next const stepDef = WORKFLOW_STEPS[completedStep];
for (const nextStep of stepDef.next) { const nextDef = WORKFLOW_STEPS[nextStep];
// Check fan-in: are all prerequisites complete? if (nextDef.waitFor) { const allReady = nextDef.waitFor.every( dep => workflow.completedSteps.includes(dep) ); if (!allReady) continue; // Wait for other branches }
// Build payload from previous results const stepPayload = { ...workflow.context, previousResults: workflow.results, };
await dispatchStep(workflowId, nextStep, stepPayload); }
// Check if workflow is complete const allSteps = Object.keys(WORKFLOW_STEPS); const allDone = allSteps.every( s => workflow.completedSteps.includes(s) );
if (allDone) { await updateWorkflow(workflowId, { status: 'completed' }); }
res.status(200).json({ received: true });});Step 4: Handle Failures and Partial Completions
When a step fails after exhausting its retries, compensation logic must undo work from earlier steps:
app.post('/api/orchestrator/failed', async (req, res) => { const { workflowId, step: failedStep } = req.body.payload; const { error } = req.body;
const workflow = await getWorkflow(workflowId);
await updateWorkflow(workflowId, { [`steps.${failedStep}`]: 'failed', status: 'failed', error: { step: failedStep, message: error }, });
// Compensate completed steps in reverse order const compensations = { charge: async (results) => { await refundPayment(results.charge.transactionId); }, provision: async (results) => { await deprovisionAccount(results.provision.accountId); }, };
for (const completedStep of [...workflow.completedSteps].reverse()) { if (compensations[completedStep]) { await compensations[completedStep](workflow.results); } }
await notifyOpsTeam(workflowId, failedStep, error); res.status(200).json({ received: true });});Key principles:
- Compensate in reverse order so dependencies are unwound correctly
- Make compensations idempotent since they may also need retries
- Log everything so you can audit what was rolled back
Step 5: Add Observability to Your Workflows
Track timing and status for every step to monitor health and debug failures:
app.get('/api/workflows/:id', async (req, res) => { const workflow = await getWorkflow(req.params.id);
res.json({ workflowId: workflow.id, type: workflow.type, status: workflow.status, steps: Object.entries(WORKFLOW_STEPS).map(([name]) => ({ name, status: workflow.steps?.[name] || 'pending', result: workflow.results?.[name] || null, })), error: workflow.error || null, createdAt: workflow.createdAt, });});In production, extend your orchestrator with these additions:
- Duration tracking: Record
startedAtandcompletedAtper step to identify bottlenecks - Alerting: Notify your team when workflows stay in
runningstate beyond an expected threshold - Metrics: Track completion rates, average duration, and failure rates per step
Orchestration vs. Choreography
This guide covers orchestration, where a central controller manages the workflow. The alternative is choreography, where each service reacts to events independently with no central coordinator. Orchestration works best when you need visibility, centralized error handling, and explicit control flow. Choreography suits services that are truly independent and loosely coupled.