Coordinating Callbacks With Thunks

The same dashboard exercise, solved again, this time with thunks instead of a shared object and a loop. No global state, no array to scan, just nesting, and one decision that makes or breaks it: lazy or active.

July 18, 20264 min read9 / 10Exercise

The last post introduced thunks as a wrapper that hides whether a value arrived instantly or later. Time to prove that's actually useful: the same dashboard exercise from a few posts back, rebuilt with thunks instead of a shared responses object and a scanning loop.

The Challenge, Again

Same three stats, inventory, shipping, payments, same rules: fire all three at once, render each as soon as possible, but only in order, and never twice. This time, no global variables and no array to loop over. Just functions and closures, used differently.

JavaScript ยท Live Editor
Loading editor...

Solve It Backwards

The easiest way in is to assume the hard part already works, and figure out how you'd use it first. Say getStatThunk(name) already returns a working thunk. Then getting all three, in order, is just this:

JavaScript
const inventoryThunk = getStatThunk('inventory'); const shippingThunk = getStatThunk('shipping'); const paymentsThunk = getStatThunk('payments'); inventoryThunk((data) => { console.log(data); shippingThunk((data) => { console.log(data); paymentsThunk((data) => { console.log(data); console.log('All stats loaded'); }); }); });

No loop. No shared object. Just three nested calls. Whichever thunk actually finishes first doesn't matter, because none of them get consumed until the one before it in the nesting has already produced a value.

Why the Thunk Has to Be Active, Not Lazy

Here's the part that decides whether this actually works: does getStatThunk start fetching the moment it's called, or does it wait until the returned thunk itself gets called with a callback?

A lazy thunk waits. It wouldn't start fetchInventory until inventoryThunk is actually invoked, wouldn't start fetchShipping until shippingThunk is invoked, and so on. That's sequential, not concurrent, since the shipping request would only ever start after the inventory one has already finished. It would still print in the right order, but it would violate the entire point of firing all three at once.

An active thunk starts working the instant it's created, before anyone calls it with a callback at all. All three requests fire together, the moment all three thunks are created, and calling each thunk later with a callback just asks, "is your value ready yet, or should I wait for it?"

A lazy thunk only starts its request when called, serializing three fetches by accident. An active thunk starts all three the instant it's created. ExpandA lazy thunk only starts its request when called, serializing three fetches by accident. An active thunk starts all three the instant it's created.

Building the Active Thunk

Every thunk faces exactly one of two possible timings: its underlying fetch finishes before anyone calls it with a callback, or someone calls it with a callback before the fetch finishes. Both cases need to be bridged, and closure is the only tool available to do it.

JavaScript
function getStatThunk(name) { let data; // holds the result, if it arrives before anyone's listening let waitingCb; // holds the callback, if someone's listening before it arrives fakeFetchStat(name, (_, result) => { if (waitingCb) { waitingCb(result); } else { data = result; } }); return function (cb) { if (data !== undefined) { cb(data); } else { waitingCb = cb; } }; }

Notice the fetch call sits outside the returned function. That's the entire difference between lazy and active: it runs the instant getStatThunk is called, not the instant the thunk itself is called.

Two closure variables bridge the race: data catches a result that arrives early, waitingCb catches a callback that arrives early. Whichever one shows up first, the other one, whenever it finally shows up, completes the handoff.

Why This Beats the Loop-and-Object Version

Run this and every arrival order still prints in the same fixed sequence, inventory, shipping, payments, exactly like the original solution. But there's no shared state to accidentally corrupt, no array index to get wrong, and no scanning loop to re-derive on every call. The correctness comes from nesting and closure alone.

That's a genuinely better piece of plumbing. It's still not perfect, still nested, still built entirely on callbacks underneath, but it's a real step up from manually managing an object and a loop by hand.

The Essentials

  1. A thunk used for coordination has to be active, not lazy. Active starts the work immediately on creation; lazy waits for the first call, which can accidentally serialize requests that were supposed to run in parallel.
  2. Nesting three thunk calls guarantees order without a loop or shared object. Each level only runs once the thunk before it has already produced a value.
  3. An active thunk bridges exactly two possible race outcomes with closure: value-arrives-first, or callback-arrives-first. Two variables are enough to cover both.
  4. This is meaningfully less error-prone than manual shared state, since there's no array to index into and no object to scan by hand.
  5. It's still not a full fix. Thunks are still callbacks underneath, still nested, still without a trust guarantee. The next post is honest about exactly what this pattern did and didn't solve.