Aggregating Microservice Responses at the Edge

After working through this guide you will be able to build a fan-out/fan-in endpoint inside a single Cloudflare Worker invocation: issue parallel subrequests to several internal services, bound each one with its own AbortSignal.timeout, collect the results with Promise.allSettled, reshape them into one client-facing payload, and return a useful partial response with a warnings array when an upstream misbehaves instead of collapsing the whole call into a 500.

Composition is the job a mobile client cannot do for itself. A product page needs catalog metadata, live pricing, and a review summary; done from the handset that is three TLS handshakes and three round trips over a lossy radio link. Done inside an edge API gateway, it is one client request, three warm subrequests over the provider’s backbone, and one response whose latency is set by the slowest upstream rather than the sum of all of them. The engineering work is entirely in the failure and timing behavior — a naive Promise.all composition is strictly worse than three separate calls, because any single upstream hiccup takes the whole page down.

Key implementation objectives:

  • Fan out to every upstream at once and fan back in with Promise.allSettled, so one rejection never cancels the rest.
  • Give each upstream its own deadline with AbortSignal.timeout and classify it as required or optional.
  • Emit a partial 200 carrying a warnings array when an optional fragment is missing, and reserve 502 for genuinely required data.
  • Choose a cache policy deliberately — per-fragment or per-composition — and stream the first bytes when a slow upstream would otherwise hold the response.
Fan-out and fan-in inside one Worker invocation A client makes one request to a composer Worker, which issues three parallel subrequests to catalog, pricing and reviews services, records a timeout from reviews as a warning, and returns a single composed JSON body. One client request, three parallel subrequests Client one round trip Composer Worker 1. fan out 2. allSettled 3. merge + shape warnings[] reviews: upstream_timeout catalog-svc required fragment 200 in 41 ms pricing-svc required fragment 200 in 88 ms reviews-svc optional fragment aborted at 250 ms request composed JSON fan-out The slowest upstream sets the floor; a failed optional fragment becomes a warning, not a 500

Prerequisites and environment setup

You need Node 18 or newer, Wrangler 3.60+, and a Cloudflare account. Composition itself works on the Free plan, but the subrequest ceiling is much lower there, so a production aggregator belongs on Workers Paid. Confirm the toolchain first:

node --version           # v18.x or later
npx wrangler --version   # 3.60.0 or later
npx wrangler whoami      # account + auth check

The upstream services must be reachable from the Worker over HTTPS — either public hostnames restricted by a shared secret, Cloudflare Tunnel hostnames, or service bindings to other Workers. There is no TCP socket and no connection pool at the edge, so every upstream call is an HTTP fetch().

{
  "name": "product-composer",
  "main": "src/index.ts",
  "compatibility_date": "2024-09-23",
  "vars": {
    "CATALOG_URL": "https://catalog.internal.example.com",
    "PRICING_URL": "https://pricing.internal.example.com",
    "REVIEWS_URL": "https://reviews.internal.example.com",
    "UPSTREAM_BUDGET_MS": "250"
  },
  "observability": { "enabled": true }
}
npx wrangler secret put UPSTREAM_SHARED_SECRET

Put this Worker behind the same auth layer you already run — validate the caller once with JWT validation at the edge, then forward the verified subject to each upstream as a header so no service re-parses the token. Because one client call now costs three upstream calls, keep the aggregator behind rate limiting as well; an unthrottled composed endpoint is a traffic multiplier pointed at your own backends.

Budget the latency before writing code

Composition only pays off when the fan-out is genuinely parallel. Sequential chaining — fetch the catalog, then use its ID to fetch pricing, then fetch reviews — adds every hop together and is almost always the result of an accidental await inside a loop rather than a real data dependency. Measure both shapes before you commit to a budget.

Sequential chaining versus parallel fan-out Two timelines on a shared millisecond axis: three upstream calls run back to back for 198 milliseconds, or overlapped so the total is the 88 millisecond slowest call. Sequential chaining versus parallel fan-out (p50) Sequential catalog pricing reviews 198 ms wall clock — every hop waits for the one before it Parallel catalog 40 ms pricing 88 ms reviews 70 ms 88 ms wall clock — the slowest upstream sets the floor 0 50 100 150 200 elapsed milliseconds inside the Worker invocation

Write the budget down as a table and treat the total as a service-level objective, not an aspiration. The numbers below are from a three-upstream product endpoint with the services in the same region and Argo smart routing enabled between the PoP and the origin region.

Stage p50 p99 Who controls it
TLS + isolate start at the PoP 1 ms 4 ms Platform; isolates have no cold start
Auth + limit check before fan-out 3 ms 9 ms Your gateway code
catalog-svc subrequest 40 ms 95 ms Catalog team, plus edge-to-origin RTT
pricing-svc subrequest 88 ms 210 ms Pricing team; the current critical path
reviews-svc subrequest 70 ms 250 ms (deadline) Capped by AbortSignal.timeout
Merge, reshape, serialize 4 ms 11 ms CPU only — counts against the CPU budget
Composed response 96 ms 266 ms Slowest upstream + merge, never the sum

The p99 column is the one that matters. Without a deadline, the composed p99 is unbounded: it becomes whatever the worst upstream does on its worst day. AbortSignal.timeout converts that open-ended tail into a fixed, known cost.

Step-by-step procedure

Step 1 — Declare the upstream map with per-upstream deadlines

Describe each upstream declaratively: where it lives, how long it may take, and whether the response is usable without it. A required fragment that fails produces an error; an optional one produces a warning. Encoding this in data rather than in branching code keeps the merge step honest as the endpoint grows.

interface Env {
  CATALOG_URL: string;
  PRICING_URL: string;
  REVIEWS_URL: string;
  UPSTREAM_BUDGET_MS: string;
  UPSTREAM_SHARED_SECRET: string;
}

interface Upstream {
  name: string;
  url: (id: string, env: Env) => string;
  timeoutMs: number;
  required: boolean;
}

const UPSTREAMS: Upstream[] = [
  { name: "catalog", url: (id, e) => `${e.CATALOG_URL}/items/${id}`,      timeoutMs: 150, required: true  },
  { name: "pricing", url: (id, e) => `${e.PRICING_URL}/price/${id}`,      timeoutMs: 250, required: true  },
  { name: "reviews", url: (id, e) => `${e.REVIEWS_URL}/summary/${id}`,    timeoutMs: 250, required: false },
];

Set each deadline slightly above that upstream’s own p99, not at the composed budget. A deadline generous enough to never fire is decoration; one tighter than the upstream’s normal tail turns a healthy service into a permanent warning.

Step 2 — Fan out with AbortSignal.timeout and fan in with Promise.allSettled

The critical detail is that every fetch() is started before the first await. Calling fetch returns a promise immediately, so building the array of promises fires all three requests inside the same tick; the single await Promise.allSettled(...) then waits once for the slowest. allSettled never short-circuits, so an aborted or rejected upstream still lets the others land.

type Fragment =
  | { name: string; ok: true;  data: unknown; ms: number }
  | { name: string; ok: false; error: string; ms: number };

async function callUpstream(u: Upstream, id: string, env: Env, req: Request): Promise<Fragment> {
  const started = Date.now();
  try {
    const res = await fetch(u.url(id, env), {
      // one deadline per upstream, enforced by the runtime
      signal: AbortSignal.timeout(u.timeoutMs),
      headers: {
        "x-upstream-secret": env.UPSTREAM_SHARED_SECRET,
        "x-auth-sub": req.headers.get("x-auth-sub") ?? "",
        "x-request-id": req.headers.get("x-request-id") ?? crypto.randomUUID(),
        accept: "application/json",
      },
      cf: { cacheTtl: 30, cacheEverything: true },
    });
    if (!res.ok) {
      return { name: u.name, ok: false, error: `upstream_status_${res.status}`, ms: Date.now() - started };
    }
    return { name: u.name, ok: true, data: await res.json(), ms: Date.now() - started };
  } catch (err) {
    const aborted = (err as Error).name === "TimeoutError" || (err as Error).name === "AbortError";
    return {
      name: u.name,
      ok: false,
      error: aborted ? "upstream_timeout" : "upstream_unreachable",
      ms: Date.now() - started,
    };
  }
}

async function fanOut(id: string, env: Env, req: Request): Promise<Fragment[]> {
  // every fetch is in flight before the first await
  const settled = await Promise.allSettled(UPSTREAMS.map((u) => callUpstream(u, id, env, req)));
  return settled.map((s, i) =>
    s.status === "fulfilled"
      ? s.value
      : { name: UPSTREAMS[i].name, ok: false as const, error: "handler_threw", ms: 0 },
  );
}

callUpstream already converts every failure into a resolved Fragment, so in practice allSettled and all would behave identically here. Keep allSettled anyway: it is the safety net for a bug in your own mapping code, and it costs nothing.

Step 3 — Degrade on purpose: partial bodies and a warnings array

Now decide what each outcome means. A missing optional fragment should not cost the client its whole page — return 200 with that key absent and a machine-readable entry in warnings. A missing required fragment is a real failure, and 502 with Retry-After is the honest answer.

How each fan-out outcome shapes the response The settled results branch into three response shapes: a complete cacheable 200, a partial 200 carrying warnings and no shared cache, or a 502 when a required fragment is missing. Outcome to response shape allSettled() 3 settled results nothing thrown every fragment ok 200 · complete body s-maxage=30, swr=120 optional missing 200 · partial + warnings no-store, never cached required missing 502 · fail closed Retry-After: 5 warnings[] upstream, code, ms read by the client A missing optional fragment is a warning; a missing required one is an error
function compose(fragments: Fragment[]): { status: number; body: Record<string, unknown> } {
  const byName = new Map(fragments.map((f) => [f.name, f]));
  const warnings = fragments
    .filter((f): f is Extract<Fragment, { ok: false }> => !f.ok)
    .map((f) => ({ upstream: f.name, code: f.error, elapsed_ms: f.ms }));

  const missingRequired = UPSTREAMS.filter((u) => u.required && !byName.get(u.name)?.ok);
  if (missingRequired.length > 0) {
    return {
      status: 502,
      body: { error: "upstream_unavailable", upstreams: missingRequired.map((u) => u.name), warnings },
    };
  }

  const catalog = byName.get("catalog") as Extract<Fragment, { ok: true }>;
  const pricing = byName.get("pricing") as Extract<Fragment, { ok: true }>;
  const reviews = byName.get("reviews");

  // reshape: the client sees one flat product, not three service payloads
  const c = catalog.data as any;
  const p = pricing.data as any;
  const body: Record<string, unknown> = {
    id: c.sku,
    title: c.display_name,
    description: c.long_description,
    price: { amount: p.amount_minor, currency: p.currency, valid_until: p.expires_at },
    reviews: reviews?.ok ? { score: (reviews.data as any).avg, count: (reviews.data as any).n } : null,
  };
  if (warnings.length > 0) body.warnings = warnings;
  return { status: 200, body };
}

Reshaping is not cosmetic. Returning the three upstream payloads verbatim under three keys leaks your internal service boundaries into the public contract, and every later refactor becomes a breaking API change. Flatten once, at the edge.

Step 4 — Choose a cache placement for the composition

There are two defensible placements and one common mistake. Cache each fragment independently (each subrequest carries its own cf.cacheTtl, so pricing can hold for 30 seconds while catalog holds for an hour) and the composition is rebuilt per request from mostly-warm parts. Or cache the composed response itself under a single key, which gives one clean hit but forces the shortest-lived fragment’s TTL onto the whole document.

Two cache placements for a composed response On the left each upstream fragment is cached with its own TTL and the composition is rebuilt per request; on the right one composed document is cached under a single key with the shortest TTL and a wider purge blast radius. Cache per fragment, or cache the composition Cache each fragment catalog TTL 1 h pricing TTL 30 s reviews TTL 5 m compose every request CPU each time precise TTLs, narrow purge Cache the composition GET /v1/product/42 one composed document, one key s-maxage=30 shortest fragment wins purge blast radius any upstream change drops it all one hit serves the whole page

The mistake is caching the composition without accounting for partial responses. A body that carries warnings describes a degraded moment, and storing it means every later visitor is served that outage until the TTL expires. Set no-store whenever warnings is non-empty, and only apply a shared TTL to complete responses. If the shape of your Cache-Control header is unfamiliar, the reasoning is laid out in setting Cache-Control headers for static and dynamic content; pairing a short s-maxage with stale-while-revalidate keeps a composed page fast even while one upstream is slow.

function cacheHeaders(status: number, hasWarnings: boolean): HeadersInit {
  if (status !== 200 || hasWarnings) {
    return { "cache-control": "no-store", "x-composed": "partial" };
  }
  return {
    "cache-control": "public, s-maxage=30, stale-while-revalidate=120",
    "x-composed": "complete",
  };
}

Step 5 — Stream the first bytes with TransformStream

When one fragment is slow but the rest are ready, buffering the whole document means the client waits for the laggard before it sees anything. A TransformStream lets you write the fast keys immediately and append the slow one when it lands, so time to first byte tracks the fastest upstream instead of the slowest. This changes the wire contract, so gate it behind a query flag or a content type your client opts into.

function streamCompose(id: string, env: Env, req: Request): Response {
  const { readable, writable } = new TransformStream();
  const writer = writable.getWriter();
  const enc = new TextEncoder();

  (async () => {
    try {
      await writer.write(enc.encode('{"id":' + JSON.stringify(id) + ',"fragments":['));
      let first = true;
      // resolve each upstream independently and emit it the moment it lands
      const jobs = UPSTREAMS.map((u) =>
        callUpstream(u, id, env, req).then(async (f) => {
          await writer.write(enc.encode((first ? "" : ",") + JSON.stringify(f)));
          first = false;
        }),
      );
      await Promise.allSettled(jobs);
      await writer.write(enc.encode("]}"));
    } finally {
      await writer.close();
    }
  })();

  return new Response(readable, {
    headers: { "content-type": "application/json", "transfer-encoding": "chunked", "cache-control": "no-store" },
  });
}

Two constraints come with streaming. A streamed body cannot be cached as one unit, so it is no-store by definition, and you cannot change the status code once the first byte is written — a late failure has to be reported inside the payload rather than as a 502.

Subrequest limits, CPU budget, and avoiding N+1 fan-out

Each fetch() from a Worker consumes one subrequest. The Free plan allows 50 per invocation and Workers Paid allows 1000, which sounds generous until a list endpoint loops over 200 items and calls pricing per item — that is the N+1 shape, and it will exhaust the budget, saturate the upstream, and blow the CPU limit in one go. The fix is a batch endpoint upstream: one POST /price/batch with 200 IDs replaces 200 subrequests with one.

CPU time is a separate budget from wall time. Waiting on fetch() costs no CPU, so a 250 ms fan-out is nearly free; JSON.parse and JSON.stringify over large payloads are not. Trim upstream responses with sparse fieldsets (?fields=sku,display_name) rather than parsing megabytes at the edge and discarding most of it.

Keep fan-out width bounded in code as well as by policy — a hard cap that returns 400 above, say, 20 IDs is far kinder than discovering the ceiling in production.

Verification

Confirm the composed timing with curl’s timing variables. time_starttransfer is the number to watch: on a buffered composition it tracks the slowest upstream, and on the streaming variant it should collapse toward the fastest.

curl -s -o /dev/null \
  -w "dns=%{time_namelookup} connect=%{time_connect} ttfb=%{time_starttransfer} total=%{time_total}\n" \
  -H "Authorization: Bearer $TOKEN" \
  https://api.example.com/v1/product/42
dns=0.014 connect=0.031 ttfb=0.128 total=0.131

Inspect the body and headers together to confirm the degradation contract holds while one upstream is down:

curl -s -D - -H "Authorization: Bearer $TOKEN" \
  https://api.example.com/v1/product/42 | head -20
HTTP/2 200
cache-control: no-store
x-composed: partial
{"id":"42","title":"Field Notebook","price":{"amount":1290,"currency":"USD"},
 "reviews":null,"warnings":[{"upstream":"reviews","code":"upstream_timeout","elapsed_ms":250}]}

Watch which upstream is actually slowest across real traffic rather than guessing:

npx wrangler tail --format json | jq -r '.logs[].message[] | select(type=="string")'
compose id=42 catalog=41ms pricing=88ms reviews=250ms(timeout) total=96ms
compose id=17 catalog=38ms pricing=201ms reviews=64ms total=209ms

Log the per-upstream elapsed time on every request, not just failures — the slow upstream that never times out is the one quietly setting your p99. Ship those lines off the platform so you can chart the distribution per upstream over weeks, and propagate the same x-request-id you set in Step 2 using the patterns in tracing requests across the edge with correlation headers.

Troubleshooting

One slow upstream drags every response to its deadline

If composed p99 sits exactly on your longest timeoutMs, one upstream is timing out routinely rather than occasionally. Confirm with a per-upstream histogram from the tail logs above. The fix is rarely a longer deadline: either the upstream needs a cache in front of it, or the fragment should stop being fetched inline and move to a second client request. A deadline that fires on more than about 1% of calls is a capacity problem wearing a timeout costume.

Too many subrequests under load

The error surfaces as an exception on the fetch() call once the invocation crosses the plan’s subrequest ceiling. Diagnose by counting calls per invocation — log UPSTREAMS.length * ids.length before fan-out. Almost always the cause is a loop that fans out per item. Replace it with a batch upstream call, cap the accepted list length, and remember that a redirect followed by the runtime counts as an additional subrequest.

Aborted requests still charge the upstream

AbortSignal.timeout stops your Worker waiting, but the upstream may have already started expensive work and will finish it regardless. Under a partial outage you can therefore time out at the edge while still driving the failing service into the ground. Pair the client deadline with a server-side deadline (a x-deadline-ms header the upstream honors) and, for repeat failures, a circuit breaker keyed in a Durable Object that skips the call entirely for a cooling-off window.

The composed response is cached in a degraded state

A warnings payload served from cache long after recovery is the classic composition bug. Check x-composed: partial against cf-cache-status on a response you know should be complete; if you see HIT with partial, your cache decision ran before the warnings were known. Compute Cache-Control from the final composed body, never from the request, and purge the affected paths after an incident.

TypeError: Illegal invocation or a hung stream

Writing to a TransformStream writer after close(), or forgetting await writer.close() in the finally, leaves the response hanging until the client gives up. Reproduce locally with npx wrangler dev and a curl --max-time 5; a body that stops mid-JSON confirms it. Always close in a finally, and never call writer.write from two branches without awaiting the previous write.

Frequently Asked Questions

Why use Promise.allSettled instead of Promise.all? Promise.all rejects as soon as any subrequest rejects, discarding the results that already succeeded and forcing a 500 for a single flaky upstream. allSettled waits for every promise to settle and hands you the outcome of each, which is what partial degradation requires.

Does an aborted subrequest still count against the subrequest limit? Yes. The subrequest is counted when it is issued, not when it completes, so a fan-out that times out consumes exactly the same budget as one that succeeds. Bound your fan-out width in code rather than relying on timeouts to keep the count down.

Should I cache each fragment or the composed response? Cache fragments when their TTLs differ widely or when the same fragment is reused by several composed endpoints, because each piece keeps its natural lifetime. Cache the composition when all fragments share a similar TTL and the endpoint is hot, since one key then serves the whole document with a single lookup.

How do I keep a partial response out of the shared cache? Compute the Cache-Control header from the composed body: emit no-store whenever the warnings array is non-empty or the status is not 200, and only attach s-maxage to complete responses. That single rule prevents a momentary upstream failure from being replayed to everyone for the length of the TTL.

When is streaming with TransformStream worth the complexity? When one fragment is reliably slower than the others and the client can render progressively — a product page that can paint the title and price before reviews arrive. If the client parses the whole document before rendering anything, streaming adds complexity, forfeits caching, and buys nothing.

Back to API Gateway at the Edge