Claude’s tool-calling loops have a reputation for spawning tangled promise code in Node.js.
Node.js 22’s new Promise.withResolvers() lets developers replace the boilerplate-heavy new Promise pattern with a single line that hands out the promise and its resolve/reject functions. The result is fewer forgotten resolve calls, no double-reject warnings, and a flatter control flow that’s easier to test and keep alive in serverless environments.

Why the old pattern breaks the flow

When an LLM like Claude asks a tool, the typical Node implementation looks like this:

return new Promise((resolve, reject) => {
  // launch the tool, attach callbacks, maybe fire another async call
});

Three recurring pitfalls arise:

  • Forgotten resolve – If the code path never calls resolve, a Lambda or other serverless handler hangs until it times out, driving up cost.
  • Double reject – An error path that calls reject twice triggers “unhandled rejection” warnings that can crash the process in strict mode.
  • Deep nesting – Each async step nests another callback inside the constructor, scattering the logic and making unit tests brittle.

All of those issues stem from the fact that the promise’s control functions are locked inside the constructor’s closure, forcing the rest of the code to reach back into it.

Promise.withResolvers() in one line

Node 22 adds a static helper that returns an object containing a promise and the two functions that settle it:

const { promise, resolve, reject } = Promise.withResolvers();

Now the promise can be handed off to any part of the system—an HTTP handler, a database listener, or a background worker—while the original caller simply awaits the promise. There’s no need to wrap the whole tool-execution block in a new Promise constructor.

Applying it to Claude’s tool loop

Claude’s workflow is:

  1. LLM emits a tool request.
  2. Your code runs the tool (e.g., an API call, a file read).
  3. The tool’s result is sent back to Claude for the next turn.

With withResolvers, the loop collapses to:

async function runTool(request) {
  const { promise, resolve, reject } = Promise.withResolvers();

  // Kick off the tool; it can call resolve/reject from anywhere
  executeTool(request, { resolve, reject });

  // Optional timeout wrapper
  const timeout = setTimeout(() => reject(new Error('Tool timed out')), 10_000);
  try {
    const result = await promise;
    clearTimeout(timeout);
    return result;               // feed back to Claude
  } finally {
    // clean-up if needed
  }
}

The tool implementation no longer needs to be wrapped in a new promise; it just receives resolve and reject. This eliminates the three failure modes listed above.

Production knobs that still matter

Even with a cleaner promise shape, real-world agents hit other constraints:

  • Timeouts – The snippet above shows a simple timer that rejects if the tool exceeds a threshold. Adjust the duration based on SLA expectations.
  • Throttling – When the underlying service returns a throttling error (e.g., Bedrock’s ThrottlingException), catch it, pause, and retry with exponential back-off. The resolve/reject pair remains the same; only the retry logic changes.
  • Lambda cost – In AWS Lambda, set callbackWaitsForEmptyEventLoop = false. This tells the runtime to terminate the function as soon as the handler returns, even if streams or other background handles are still open. It prevents the function from lingering while the promise settles elsewhere.

When the new helper isn’t a silver bullet

Promise.withResolvers() is only available in Node 22 and later. Projects locked to older LTS releases must either polyfill the pattern or stick with the classic constructor. Polyfills can mimic the API but won’t gain the native performance benefits. Moreover, the helper does not magically solve logical bugs: developers still need to ensure that exactly one of resolve or reject is called for every request, or else the promise will remain pending indefinitely.

What to watch next

  • Framework adoption – Libraries that abstract LLM agent loops (e.g., open-source Claude wrappers) are beginning to expose withResolvers as an opt-in feature. Keep an eye on updates that make the pattern the default.
  • Node ecosystem – As more services move to Node 22, the helper will become a de-facto standard for any “fire-and-wait” async pattern, not just LLM agents.
  • Tool-calling standards – Emerging specifications for LLM tool calls may prescribe a “single-promise” contract, which aligns perfectly with the withResolvers approach.

Takeaway: By swapping the verbose new Promise wrapper for a one-liner Promise.withResolvers(), Claude-based agents gain clearer flow, fewer runtime surprises, and tighter control over serverless costs—provided the runtime supports Node 22.