Orchestrate multi-API workflows

Run several APIs as a dependency graph: order steps with a topological sort, run independent calls concurrently, keep a latency budget, and merge partial failures.

  • 45 min
  • Expert

In earlier courses you chained two or three calls by hand: await the first, pass its output to the second, run the last two with Promise.all. That works until the workflow has five steps, two of them conditional, one of them slow, and a product manager asking why a lead was scored without its company data. At that size the shape of the code stops matching the shape of the problem, and every change risks reordering something that had to come first.

This lesson treats a workflow as data: a list of steps that declare what they need. From that declaration you derive the execution order, the parallelism, the latency of the critical path, and the rule for which failures matter. The two functions at the centre, planOrder and fanIn, are small enough to write from memory and general enough to reuse in every orchestration you build.

The workflow: qualify an inbound lead

The running example takes an inbound lead with an email address and the IP it signed up from, and produces one record a sales team can act on:

StepAPINeedsRequired?What it adds
emailemailvalidatornothingyesDeliverability, company vs free mail, the domain
geoiplookupnothingnoCountry and timezone of the signup
proxyvpndetectornothingnoVPN and datacenter flags
whoiswhoislookupemailnoDomain age, registrar, registrant organisation when published
stacktechstackdetectoremailnoWhat the company's website runs on

Two steps depend on email because they need the domain it extracts, and both only make sense for company addresses: looking up gmail.com tells you nothing about the lead. That is a conditional step, which is not the same thing as a failed one.

Drawn as a graph:

email ──┬──► whois
        └──► stack
geo
proxy

Three steps can start immediately. Two wait for email. Nothing waits for whois or stack. That picture is a directed acyclic graph (DAG): directed because dependencies point one way, acyclic because no step can, through any chain, depend on itself.

Steps as data, order by topological sort

Declare each step as an object. The orchestrator reads id and needs; it never needs to know what run does.

lead-steps.mjs
import { call } from './call.mjs';

export const steps = [
  { id: 'email', needs: [], required: true, timeoutMs: 2000,
    run: ({ input, signal }) => call('emailvalidator', { email: input.email }, { signal }) },
  { id: 'geo', needs: [], timeoutMs: 1500,
    run: ({ input, signal }) => call('iplookup', { ip: input.ip }, { signal }) },
  { id: 'proxy', needs: [], timeoutMs: 1500,
    run: ({ input, signal }) => call('vpndetector', { ip: input.ip }, { signal }) },
  { id: 'whois', needs: ['email'], timeoutMs: 3000,
    when: ({ email }) => email?.isCompanyEmail === true,
    run: ({ deps, signal }) => call('whoislookup', { domain: deps.email.domain }, { signal }) },
  { id: 'stack', needs: ['email'], timeoutMs: 3000,
    when: ({ email }) => email?.isCompanyEmail === true,
    run: ({ deps, signal }) => call('techstackdetector', { url: `https://${deps.email.domain}` }, { signal }) },
];

Before running anything, turn the declaration into an order in which every step comes after everything it needs. That is a topological sort. The version below adds two properties that matter in practice: it is deterministic, because when several steps are ready at once it picks the one that appears first in the input, and it refuses cycles with an error that names the steps involved.

plan.mjs
export function planOrder(steps) {
  const ids = new Set();
  for (const s of steps) {
    if (ids.has(s.id)) throw new Error(`duplicate step id "${s.id}"`);
    ids.add(s.id);
  }
  for (const s of steps) {
    for (const n of s.needs ?? []) {
      if (!ids.has(n)) throw new Error(`step "${s.id}" needs unknown step "${n}"`);
    }
  }

  const done = new Set();
  const order = [];
  while (order.length < steps.length) {
    // the first step, in input order, whose needs are all done
    const next = steps.find((s) => !done.has(s.id) && (s.needs ?? []).every((n) => done.has(n)));
    if (!next) {
      const stuck = steps.filter((s) => !done.has(s.id)).map((s) => s.id);
      throw new Error(`cycle detected among: ${stuck.join(', ')}`);
    }
    done.add(next.id);
    order.push(next.id);
  }
  return order;
}

Walk through it with steps listed out of order: [{ id: 'whois', needs: ['email'] }, { id: 'email', needs: [] }, { id: 'geo', needs: [] }]. On the first pass whois is not ready, so email is picked. On the second pass whois is now ready and comes before geo in the input, so it wins the tie. The result is ['email', 'whois', 'geo'], every time. If email also needed whois, no step would ever become ready and the loop would throw cycle detected among: whois, email.

This is the simplest correct form of Kahn's algorithm. It rescans the list on each pass, which is quadratic in the number of steps. For a workflow of five or fifty steps that costs nothing, and the code is easy to verify. If you ever orchestrate thousands of nodes, keep an in-degree count per step and a ready list ordered by input position; the output is identical.

Why bother with determinism? Because the order appears in logs, in traces and in tests. A plan that changes between runs when nothing changed makes every diff noisy and every flaky test harder to pin down.

Check yourself

planOrder receives steps in this order: a (needs nothing), b (needs a), c (needs nothing). Ties are broken by input order, picking the earliest ready step on each pass. What does it return?

Run every example on your own key. Free plan, no card.

Scaling up?

Volume pricing, custom SLAs, and dedicated support for high-traffic teams.

Contact sales