Developers still drown in new Promise((resolve,reject)=>…) boilerplate when wiring Claude’s function‑calling tool calls. The new Promise.withResolvers() helper lets you expose resolve/reject instantly, cutting noise and bugs. In this post you’ll see how a few extra lines turn a tangled async loop into crystal‑clear code.
Why the Old Promise Boilerplate Breaks
The problem in plain language
When an LLM (large language model) like Claude decides it needs a tool—say a calendar lookup—you have to pause your JavaScript code, send a request to the tool, wait for the answer, then feed that answer back to Claude. The common pattern looks like this:
function callToolWhenNeeded(request) {
return new Promise((resolve, reject) => {
// 1️⃣ send request to Claude
// 2️⃣ when Claude asks for a tool, invoke the tool
// 3️⃣ resolve the promise with the tool’s result
});
}
At first glance it seems fine, but three hidden bugs often surface:
-
Forgot to call
resolve– the promise stays pending forever, leaving your Lambda hanging. -
Calling
rejecttwice – an accidental secondrejectturns into an unhandled‑promise rejection warning. -
Scattered control flow – the
resolve/rejectfunctions live deep inside callbacks, making the code hard to read and to test.
Because the promise is created inside the function, the only way for the outer loop to “see” the result is to capture those resolver functions and expose them. Doing it manually means writing extra lines, copying variable names, and hoping you didn’t typo‑spell anything.
In plain English: The old pattern forces you to juggle two invisible levers (
resolveandreject) that live inside a black box. If you lose one, the whole machine stalls.
A simple analogy
Imagine you’re building a mailbox that delivers letters to a friend. With the old pattern you hand your friend a sealed box that contains a secret key. Only after they open the box can they drop the letter inside. If the key is misplaced, the letter never arrives. Promise.withResolvers() hands you the key up front, so you can hand it to anyone who needs it without rummaging through sealed boxes.
Introducing Promise.withResolvers()
What the new API does
Promise.withResolvers() (added in Node.js 22) creates a promise and returns an object that already contains the resolve and reject functions:
const { promise, resolve, reject } = Promise.withResolvers();
Now you have a plain promise that can be awaited, and you hold the two “levers” in variables you can pass around freely. No more nesting, no more accidental shadowing.
Why this matters for Claude’s tool‑calling loop
Claude’s invokeModel call returns a stream of JSON‑LDM (language‑model‑driven) messages. When it emits a function_call event you must:
- Execute the requested function (e.g., fetch a user profile).
- Feed the result back into the model as a new message.
With withResolvers() you can set up a single place to wait for the function’s result, while the function itself can resolve it from anywhere—inside an HTTP callback, a database query, or a third‑party SDK call.
Minimal example
// Grab a promise and its resolvers in one line
const { promise, resolve, reject } = Promise.withResolvers();
// Somewhere else, perhaps in an HTTP handler, we finish the work
async function fetchUser(id) {
try {
const user = await db.getUser(id); // pretend async DB call
resolve(user); // hand the result back
} catch (err) {
reject(err); // tell the waiting code something went wrong
}
}
// The caller simply awaits the promise
async function waitForUser(id) {
fetchUser(id); // fire‑and‑forget; resolves later
return promise; // pauses until resolve or reject is called
}
Key takeaway: One line gives you a promise and the two controls you need, removing the boilerplate that usually hides in callback hell.
Building a Claude Function‑Calling Agent
The goal
Create a Node.js 22 AWS Lambda that:
- Sends a prompt to Claude via the Bedrock Runtime SDK (
@aws-sdk/client-bedrock-runtime). - Detects a
function_callrequest from Claude. - Executes the requested tool (a mock “searchKnowledgeBase” function).
- Sends the tool’s output back to Claude.
- Streams the final answer to the HTTP client.
All of this will use Promise.withResolvers() to keep the async flow tidy.
Step‑by‑step construction
1. Install the SDK
npm install @aws-sdk/client-bedrock-runtime
2. Import required classes
import {
BedrockRuntimeClient,
InvokeModelCommand,
BedrockRuntimeServiceException,
} from "@aws-sdk/client-bedrock-runtime";
- BedrockRuntimeClient – a client that talks to AWS Bedrock’s model‑hosting endpoint.
- InvokeModelCommand – the command object that carries the request payload.
- BedrockRuntimeServiceException – a base error type for all Bedrock‑related failures.
3. Helper to parse streaming SSE (Server‑Sent Events)
Bedrock streams responses as a series of SSE frames. Node.js does not parse them automatically, so we need a tiny parser:
function parseSSE(stream: NodeJS.ReadableStream) {
const decoder = new TextDecoder();
let buffer = "";
return new Promise<string>((resolve, reject) => {
stream.on("data", (chunk) => {
buffer += decoder.decode(chunk, { stream: true });
const lines = buffer.split("\n");
// Keep the last incomplete line for next chunk
buffer = lines.pop() ?? "";
for (const line of lines) {
if (line.startsWith("data:")) {
const json = line.slice(5).trim();
if (json === "[DONE]") return resolve(""); // end of stream
try {
const payload = JSON.parse(json);
// Emit the payload to the outer promise
resolve(JSON.stringify(payload));
} catch (e) {
reject(e);
}
}
}
});
stream.on("error", reject);
stream.on("end", () => resolve(""));
});
}
Tip: The parser returns the first JSON payload it sees; for a real‑world agent you would accumulate messages until the model says it’s done.
4. The Lambda handler with Promise.withResolvers()
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
const client = new BedrockRuntimeClient({ region: "us-east-1" }); // adjust region
export async function handler(
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> {
// -----------------------------------------------------------------
// 1️⃣ Create a resolver pair that the tool function will use later
// -----------------------------------------------------------------
const { promise: toolPromise, resolve, reject } = Promise.withResolvers<any>();
// -----------------------------------------------------------------
// 2️⃣ Build the Claude request (Claude 3.5 Sonnet is a common choice)
// -----------------------------------------------------------------
const prompt = JSON.stringify({
messages: [{ role: "user", content: event.body ?? "Tell me a story." }],
// Instruct Claude that it may call the `searchKnowledgeBase` tool
toolConfig: {
tools: [
{
name: "searchKnowledgeBase",
description: "Searches the company's knowledge base.",
inputSchema: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
},
},
],
},
// Ask for streaming so we can forward chunks immediately
stream: true,
});
// -----------------------------------------------------------------
// 3️⃣ Send the request to Bedrock
// -----------------------------------------------------------------
const command = new InvokeModelCommand({
modelId: "anthropic.claude-3-5-sonnet-20240620-v1:0", // example model
contentType: "application/json",
accept: "application/json",
body: Buffer.from(prompt),
});
let modelStream: NodeJS.ReadableStream;
try {
const response = await client.send(command);
// response.body is a stream of SSE frames
modelStream = response.body as NodeJS.ReadableStream;
} catch (err) {
// -----------------------------------------------------------------
// 4️⃣ Bedrock‑specific error handling
// -----------------------------------------------------------------
const message = (err as BedrockRuntimeServiceException).message ?? "Unknown error";
return {
statusCode: 502,
body: JSON.stringify({ error: `Bedrock call failed: ${message}` }),
};
}
// -----------------------------------------------------------------
// 5️⃣ Listen for a function call from Claude
// -----------------------------------------------------------------
modelStream.on("data", async (chunk) => {
const text = chunk.toString();
// Simple detection – in real code you would parse the JSON payload
if (text.includes(`"name":"searchKnowledgeBase"`)) {
// Extract the query (naïve regex for demo)
const match = /"arguments":\s*"([^"]+)"/.exec(text);
const query = match ? match[1] : "default query";
// Fire the tool and let it resolve the promise
// No need for another `new Promise` – we already have resolve/reject
mockSearchKnowledgeBase(query).then(resolve).catch(reject);
}
});
// -----------------------------------------------------------------
// 6️⃣ Wait for the tool’s answer (or an error) using the promise we created
// -----------------------------------------------------------------
let toolResult: any;
try {
toolResult = await toolPromise; // pauses here until resolve() or reject()
} catch (toolErr) {
// If the tool failed, tell Claude about the failure and stop
return {
statusCode: 500,
body: JSON.stringify({ error: `Tool failed: ${toolErr}` }),
};
}
// -----------------------------------------------------------------
// 7️⃣ Feed the tool result back to Claude as a new message
// -----------------------------------------------------------------
const followUp = JSON.stringify({
messages: [
{ role: "assistant", content: "Calling tool..." },
{ role: "tool", name: "searchKnowledgeBase", content: JSON.stringify(toolResult) },
],
stream: true,
});
const followUpCmd = new InvokeModelCommand({
modelId: "anthropic.claude-3-5-sonnet-20240620-v1:0",
contentType: "application/json",
accept: "application/json",
body: Buffer.from(followUp),
});
// Send the follow‑up and stream its output back to the HTTP client
const followUpResp = await client.send(followUpCmd);
const followUpStream = followUpResp.body as NodeJS.ReadableStream;
// Collect final output (for simplicity we concatenate)
const finalOutput = await parseSSE(followUpStream);
return {
statusCode: 200,
body: finalOutput,
headers: { "Content-Type": "application/json" },
};
}
// -----------------------------------------------------------------
// Mock implementation of the knowledge‑base search tool
// -----------------------------------------------------------------
function mockSearchKnowledgeBase(query: string): Promise<{ answer: string }> {
return new Promise((res) => {
setTimeout(() => {
res({ answer: `Results for "${query}" (mocked).` });
}, 300); // simulate latency
});
}
What changed compared to the classic pattern?
Classic new Promise pattern |
Using Promise.withResolvers()
|
|---|---|
new Promise((resolve,reject)=>{ … }) buried inside the function. |
const {promise, resolve, reject}=Promise.withResolvers(); declared once, visible to every callback. |
| Resolve/reject often passed via closure, easy to lose scope. | Resolve/reject are plain variables you can pass anywhere. |
| Extra lines for “return new Promise…”. | Single line creates both the promise and its controls. |
In plain English:
Promise.withResolvers()is like getting the remote control for a TV before you even turn it on. You can hand the remote to any helper, and the TV will respond when they press a button.
Handling Errors and Timeouts Gracefully
Why error handling matters in an LLM loop
When you involve external services (Bedrock, a database, a knowledge base), three failure modes dominate:
-
Model‑side errors – Bedrock can return
ThrottlingExceptionif you exceed token limits per minute. - Tool‑side errors – Your custom function might throw, causing the promise to reject.
- Timeouts – The model or the tool may take longer than your Lambda’s remaining execution time.
If any of these errors slip through without a proper catch, Node.js will emit an unhandled‑promise rejection warning, and the Lambda may terminate silently. That is the most common hidden bug developers encounter when they forget to resolve the promise before the function exits.
Adding a timeout wrapper
A small helper that races the tool promise against a timer:
function withTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`Operation timed out after ${ms}ms`));
}, ms);
p.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(err) => {
clearTimeout(timer);
reject(err);
}
);
});
}
You then wrap the tool call:
const toolResult = await withTimeout(toolPromise, 2_000); // 2 seconds max
If the tool doesn’t call resolve within two seconds, the timeout rejects, and the Lambda returns a clean error response.
Distinguishing Bedrock error types
try {
await client.send(command);
} catch (err) {
if (err.name === "ThrottlingException") {
// Token limit per minute exceeded – back off and retry later
return {
statusCode: 429,
body: JSON.stringify({ error: "Rate limit hit – try again in a few seconds." }),
};
}
if (err instanceof BedrockRuntimeServiceException) {
// Generic Bedrock service error
return {
statusCode: 502,
body: JSON.stringify({ error: `Bedrock error: ${err.message}` }),
};
}
// Unexpected error
throw err; // let Lambda surface it
}
Tip: Always inspect
err.nameorerr.$metadata.httpStatusCodeto decide whether you should retry (throttling) or surface a hard failure.
Polyfilling for older Node versions
If you run this code on Node.js 20 (or earlier), Promise.withResolvers does not exist. A tiny polyfill is enough:
if (!Promise.withResolvers) {
// @ts-ignore – augment the global Promise type at runtime
Promise.withResolvers = () => {
let resolve!: (value: any) => void;
let reject!: (reason?: any) => void;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
}
Place the polyfill at the top of your Lambda file so the rest of the code can stay unchanged.
Running the Loop in a Serverless Lambda
Why Lambda adds its own constraints
A Lambda function has a maximum execution time (up to 15 minutes) and a memory ceiling that influences network throughput. When you combine streaming responses from Bedrock with a tool that may block, you need to be careful not to let the event loop stay alive after the promise resolves—otherwise the Lambda will keep running and you’ll be billed for idle time.
Ensuring the event loop closes
export async function handler(...): Promise<APIGatewayProxyResult> {
// ... existing code ...
// After we have the final output, tell Lambda we’re done.
// `context.callbackWaitsForEmptyEventLoop` is false by default in newer runtimes,
// but we set it explicitly for clarity.
const context = (global as any).awsLambdaContext as { callbackWaitsForEmptyEventLoop: boolean };
if (context) {
context.callbackWaitsForEmptyEventLoop = false;
}
return {
statusCode: 200,
body: finalOutput,
};
}
Setting callbackWaitsForEmptyEventLoop = false tells the runtime that it can freeze the function as soon as the handler returns, even if there are lingering streams or timers. This eliminates the hidden “dangling promise” bug that often appears when developers forget to resolve a promise before the handler exits.
Dealing with Bedrock rate limits in Lambda
Because Bedrock limits tokens per minute across the whole account, a burst of Lambda invocations can quickly hit the ceiling. A practical mitigation:
async function invokeWithBackoff(cmd: InvokeModelCommand, attempts = 3): Promise<any> {
for (let i = 0; i < attempts; i++) {
try {
return await client.send(cmd);
} catch (err) {
if (err.name === "ThrottlingException") {
const delay = 500 * (i + 1); // exponential back‑off
await new Promise((r) => setTimeout(r, delay));
continue;
}
throw err; // non‑throttling errors bubble up
}
}
throw new Error("Exceeded retry attempts for Bedrock throttling.");
}
Replace client.send(command) with invokeWithBackoff(command) in the handler. The back‑off respects the per‑minute token ceiling while still giving most requests a chance to succeed.
Key takeaway: In a serverless environment you must (1) close the event loop fast, (2) respect Bedrock’s per‑minute token budget, and (3) guard every async boundary with timeouts and proper error handling.
The Takeaway
What you now have in your toolbox
-
Promise.withResolvers()creates a promise and hands you theresolve/rejectfunctions in a single, readable line. - By exposing those resolvers, you eliminate the hidden “forgot to resolve” bugs that plague Claude function‑calling loops.
- A tiny SSE parser lets you turn Bedrock’s streaming output into usable JSON without pulling in heavy libraries.
- Centralized error handling distinguishes Bedrock throttling, generic service errors, and tool‑side failures, allowing graceful fallback or retry.
- A reusable
withTimeouthelper protects your Lambda from hanging forever when a tool misbehaves. - Setting
callbackWaitsForEmptyEventLoop = falseguarantees the Lambda shuts down promptly after the promise resolves, saving money and avoiding hidden rejections.
With these pieces, wiring Claude’s function‑calling tool calls becomes a matter of a few clear steps rather than a maze of nested promises. Happy coding!
Transparency notice
This article was written with the help of an AI system — Groq (GPT OSS 120B).
Published: 2026-09-17 · Primary focus: JavaScriptPatterns
All code blocks are intended to be correct and runnable, but please verify them
against the MDN JavaScript docs before using in production.Find an error? Drop a comment — corrections are always welcome.
Top comments (0)