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:
| Step | API | Needs | Required? | What it adds |
|---|---|---|---|---|
email | emailvalidator | nothing | yes | Deliverability, company vs free mail, the domain |
geo | iplookup | nothing | no | Country and timezone of the signup |
proxy | vpndetector | nothing | no | VPN and datacenter flags |
whois | whoislookup | email | no | Domain age, registrar, registrant organisation when published |
stack | techstackdetector | email | no | What 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
proxyThree 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.
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.
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.
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 it: fan out, fan in
A topological order is a valid sequence, but running steps one by one wastes the graph. The better executor starts every step as soon as its own needs finish. Creating one Promise per step, in planned order, does exactly that: when you create a step's Promise, the Promises of everything it needs already exist, because the order guarantees they came first.
import { planOrder } from './plan.mjs';
export async function runGraph(steps, input, { budgetMs = 2500 } = {}) {
const byId = new Map(steps.map((s) => [s.id, s]));
const deadline = Date.now() + budgetMs;
const pending = new Map();
for (const id of planOrder(steps)) {
const step = byId.get(id);
pending.set(id, (async () => {
const upstream = await Promise.all((step.needs ?? []).map((n) => pending.get(n)));
const failed = upstream.find((r) => !r.ok);
if (failed) return { step: id, ok: false, error: `skipped: ${failed.step} failed` };
const deps = Object.fromEntries(upstream.map((r) => [r.step, r.data]));
if (step.when && !step.when(deps)) return { step: id, ok: true, data: null, skipped: true };
const remaining = deadline - Date.now();
if (remaining <= 0) return { step: id, ok: false, error: 'latency budget exhausted' };
const signal = AbortSignal.timeout(Math.min(step.timeoutMs ?? remaining, remaining));
try {
return { step: id, ok: true, data: await step.run({ input, deps, signal }) };
} catch (err) {
return { step: id, ok: false, error: err.message };
}
})());
}
return Promise.all(pending.values());
}Three design choices in that function are worth naming.
Every step resolves; none rejects. A step's Promise always settles to an outcome object, { step, ok, data } or { step, ok, error }. That is what lets Promise.all wait for everything without short-circuiting on the first failure, and what lets a dependent step see that its input failed and record why it was skipped.
A skipped conditional step is a success with no data. whois for a Gmail lead did not fail; it correctly did nothing. Recording it as ok: true, skipped: true keeps it out of your error counts and alerting, where "failed" would page someone over a free-mail signup.
The deadline is shared. Each step's timeout is the smaller of its own cap and what is left of the workflow's budget. A step that starts late gets less time; a step that starts after the budget is spent does not start at all.
The step functions receive the signal and should pass it through to fetch, so a timeout actually cancels the request:
export async function call(id, params, { signal } = {}) {
const url = new URL(`https://api.apiverve.com/v1/${id}`);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v));
const res = await fetch(url, { headers: { 'x-api-key': process.env.APIVERVE_API_KEY }, signal });
const body = await res.json().catch(() => null);
if (!res.ok || body?.status !== 'ok') {
const msg = typeof body?.error === 'string' ? body.error : JSON.stringify(body?.error ?? null);
throw Object.assign(new Error(`${id}: HTTP ${res.status} ${msg}`), { status: res.status });
}
return body.data;
}Note the typeof check: a validation 400 can carry error as an object, such as {"invalid": [...]}, and an orchestrator that assumes a string will crash while trying to report a perfectly ordinary bad input.
Fan-out multiplies load. One lead fans out to up to five calls; fifty leads processed at once is up to 250 concurrent requests from one key. Your plan's per-minute rate limit applies to all of them together (the rate limits page lists the per-plan ceilings), so put a concurrency limiter around call() once, at the process level, rather than letting each workflow decide for itself.
Spend a latency budget along the critical path
The latency of a graph is not the sum of its steps. It is the longest path through it, the critical path. Using the catalog's latency estimates as a first approximation:
| Path | Estimate |
|---|---|
email then whois | 325 + 1525 = 1850 ms |
email then stack | 325 + 1500 = 1825 ms |
geo | 235 ms |
proxy | 342 ms |
| All five in sequence | about 3,930 ms |
Run concurrently, this workflow costs roughly the email then whois path, not the sum. When we ran the full graph for a company address, all five steps finished in about 1.5 seconds; the same calls in sequence would have taken several times that. Replace the estimates with your own measured p95 from the proof of concept in lesson one, because the budget has to hold for the slow calls, not the typical ones.
Now set the budget from the product, not from the APIs. If the lead score must appear on the confirmation page within 2.5 seconds, the request path gets 2,500 ms in total. Check each path against it at p95. When a path does not fit, a shorter timeout is the wrong fix: a timeout that fires on most calls is a feature that silently never works. Move the step out of the request path instead. Here, stack is useful for sales routing but nobody waits for it on a confirmation page, so the honest design runs email, geo, proxy and whois inline and queues stack as a background step that updates the record later. Lesson three builds that background side.
Required versus optional: fan in
When the graph finishes you have an array of outcomes, one per step. Turning them into a single record is the fan-in, and it is where the product decision you made in lesson one becomes code: which facts the workflow cannot do without.
export function fanIn(results, required = []) {
const record = {};
const errors = [];
for (const r of results) {
if (r.ok) {
record[r.step] = r.data ?? null;
} else {
record[r.step] = null;
errors.push({ step: r.step, error: String(r.error?.message ?? r.error) });
}
}
const fatal = errors.filter((e) => required.includes(e.step));
if (fatal.length) {
const err = new Error(`required step failed: ${fatal.map((e) => e.step).join(', ')}`);
err.errors = errors;
throw err;
}
return { ...record, errors };
}Used together:
const outcomes = await runGraph(steps, { email: 'jane@acme.example', ip: '203.0.113.7' });
const lead = fanIn(outcomes, steps.filter((s) => s.required).map((s) => s.id));
// { email: {...}, geo: {...}, proxy: null, whois: {...}, stack: null,
// errors: [{ step: 'proxy', error: 'vpndetector: HTTP 503 ...' }] }The rules it encodes:
- Data is keyed by step, never merged flat. Flattening every
dataobject into one looks tidy until two APIs use the same field name. They do: run the two calls below and both returnasnfor the same IP. A flat merge keeps whichever step finished last, which changes from run to run. - A failed step leaves an explicit
nulland a matching entry inerrors. Downstream code sees "we tried and did not get it", which is different from a step that was never part of the workflow. - Only required failures throw, and the thrown error still carries the full
errorslist, so the caller can log every failure, not just the fatal one. - Optional failures travel with the result. The record that reaches the sales team says which facts are missing, so nobody mistakes "no WHOIS data" for "WHOIS said nothing interesting".
curl "https://api.apiverve.com/v1/iplookup?ip=8.8.8.8" \
-H "x-api-key: YOUR_API_KEY"Note the asn field. Now run VPN Proxy Detector for the same IP: it returns asn too.
curl "https://api.apiverve.com/v1/vpndetector?ip=8.8.8.8" \
-H "x-api-key: YOUR_API_KEY"Same asn value, different neighbours: is_datacenter and risk_level. Keying by step keeps both answers and their context.
A run produces these outcomes: email ok, geo ok, proxy failed with a timeout, whois skipped because the lead used a free-mail address, stack skipped for the same reason. What does fanIn(outcomes, ['email']) do?
Compensation: undoing your own side effects
APIVerve calls in a workflow like this are reads and transformations. They change nothing on the provider's side, so a failed run leaves nothing to clean up there, and there are no idempotency keys to manage for them. Compensation becomes your problem the moment a workflow also writes: creates the CRM record, reserves a trial, sends the welcome email, charges a card.
The pattern that keeps this manageable has two parts.
Order side effects last. Run every read step first, fan in, and only then perform writes, when you know the required facts are present. A workflow that creates the CRM record in step one and then fails on a required check has to undo the record. One that writes after the fan-in never created it.
When writes must interleave, pair each with an undo. If a later write can fail after an earlier one succeeded, register a compensating action as each write completes, and run them in reverse order on failure. This is the saga pattern, scaled down:
export async function withCompensation(actions) {
const undo = [];
try {
for (const { name, run, compensate } of actions) {
const result = await run();
if (compensate) undo.push({ name, fn: () => compensate(result) });
}
} catch (err) {
for (const u of undo.reverse()) {
try { await u.fn(); } catch (e) { console.error(`compensation ${u.name} failed`, e); }
}
throw err;
}
}Compensation is not rollback. The welcome email cannot be unsent; its compensation might be a follow-up apology, or nothing, and you decide that per action. Write the compensation next to the action so nobody adds a write without deciding what undoing it means, and log compensation failures loudly, because they are the cases a human has to finish.
Keep orchestration in code you can test
Every piece above is a plain function over plain data. That is the point: the orchestration can be tested without a network, a key or a credit.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { planOrder } from './plan.mjs';
import { runGraph } from './run-graph.mjs';
import { fanIn } from './fan-in.mjs';
test('ties follow input order', () => {
const order = planOrder([{ id: 'b', needs: ['a'] }, { id: 'a', needs: [] }, { id: 'c', needs: [] }]);
assert.deepEqual(order, ['a', 'b', 'c']);
});
test('cycles are refused', () => {
assert.throws(() => planOrder([{ id: 'x', needs: ['y'] }, { id: 'y', needs: ['x'] }]), /cycle/);
});
test('an optional failure becomes a gap, a dependent is skipped', async () => {
const steps = [
{ id: 'email', needs: [], required: true, run: async () => ({ domain: 'acme.example' }) },
{ id: 'geo', needs: [], run: async () => { throw new Error('HTTP 503'); } },
{ id: 'tz', needs: ['geo'], run: async ({ deps }) => deps.geo.timezone },
];
const lead = fanIn(await runGraph(steps, {}), ['email']);
assert.equal(lead.geo, null);
assert.deepEqual(lead.errors.map((e) => e.step), ['geo', 'tz']);
});
test('a required failure throws with every error attached', () => {
assert.throws(
() => fanIn([{ step: 'email', ok: false, error: 'HTTP 400' }, { step: 'geo', ok: true, data: {} }], ['email']),
(err) => err.errors.length === 1 && /email/.test(err.message),
);
});Run them with node --test. Tests like these are where orchestration bugs are cheapest: a cycle introduced by a new step, a conditional that silently stopped skipping, a step that was required in the product spec and optional in code. Add one test per rule in your fan-in, and the rules stay true as the workflow grows.
You now have a workflow that runs once, fast, for a request. The next lesson moves the slow and repeatable parts into a queue that enriches records over time, records where every fact came from, and knows when a fact has gone stale: Design enrichment pipelines.