Prompt of the Day: Convert Callback-Based Code to Async/Await
Part 20 of 30 — Prompt of the Day Series
The 11 PM War Story
A few years back I was brought in to help a team shipping a Node.js payment processing service. The codebase had been written circa 2014 — back when callbacks were the way to handle async operations in JavaScript. By the time I arrived, the file that handled order confirmation was 400 lines of pyramid-of-doom: fs.readFile inside a database query inside an HTTP call inside another HTTP call, each failure path handled (or more often, silently swallowed) by a separate if (err) branch scattered across six levels of indentation.
The lead dev handed me a printout — an actual printout — with arrows drawn between the callbacks because the screen wasn't wide enough to see the nesting. That's callback hell in physical form.
Modern AI coding assistants can untangle this mess in seconds. But only if you give them the right prompt.
The Prompt
Convert the following [JavaScript/Node.js/Python] callback-based function to use async/await.
Requirements:
1. Preserve all existing behavior and error handling — do not silently drop any error paths.
2. Wrap the innermost callback-based calls with util.promisify (Node.js) or asyncio equivalents (Python) rather than rewriting third-party library internals.
3. Replace nested callbacks with sequential awaits for dependent operations and Promise.all() for operations that can run in parallel.
4. Add a single top-level try/catch block that re-throws errors with the original error message intact.
5. Do not change the function signature or return type.
Here is the function:
[PASTE YOUR CODE]
Why It Works
This prompt does five things that generic "convert to async/await" prompts miss:
It anchors behavior, not just syntax. The biggest risk in a callback-to-async migration is silently dropping an error path. Callback code often has asymmetric error handling — some branches call callback(err), others just return. By explicitly requiring all error paths to be preserved, you force the model to audit each branch rather than pattern-matching the structure.
It specifies the right conversion tool. In Node.js, util.promisify is the correct idiom for wrapping standard callback functions (anything that follows the (err, result) convention). OneUptime's 2026 deep dive on Node.js callback modernization shows that util.promisify with .bind() is the safest path for class methods and avoids a common this-context bug that manual Promise wrappers introduce. Telling the AI which tool to use means you get idiomatic, maintainable output instead of home-rolled wrapper functions.
It distinguishes sequential vs. parallel. Without this instruction, most AI assistants will convert callback(a, () => callback(b, ...)) into a chain of sequential await a; await b; — which is functionally correct but 2x slower when a and b don't depend on each other. Calling out Promise.all() for parallel paths gets you performance gains for free.
It preserves the function signature. AI models love to refactor. Unconstrained, they'll rename parameters, change return types, and wrap the whole thing in a class. Adding "do not change the function signature" keeps the migration surgical.
It mandates a single try/catch. IN-COM's 2025 guide to escaping callback hell identifies centralized error handling as the primary practical benefit of async/await over callbacks. One try/catch that re-throws with the original message gives upstream callers the error context they need without you scattering error checks across the function body.
A Real Conversion: Before and After
Before (Callback Hell — Node.js)
function processOrder(orderId, callback) {
db.query('SELECT * FROM orders WHERE id = ?', [orderId], function(err, order) {
if (err) return callback(err);
if (!order) return callback(new Error('Order not found'));
inventory.check(order.productId, function(err, inStock) {
if (err) return callback(err);
if (!inStock) return callback(new Error('Out of stock'));
payment.charge(order.customerId, order.amount, function(err, receipt) {
if (err) return callback(err);
db.query('UPDATE orders SET status = ? WHERE id = ?',
['paid', orderId],
function(err) {
if (err) return callback(err);
email.send(order.customerEmail, receipt, function(err) {
if (err) return callback(err); // silently swallowed in the original
callback(null, receipt);
});
}
);
});
});
});
}
After (Async/Await)
const { promisify } = require('util');
const dbQuery = promisify(db.query).bind(db);
const inventoryCheck = promisify(inventory.check).bind(inventory);
const paymentCharge = promisify(payment.charge).bind(payment);
const emailSend = promisify(email.send).bind(email);
async function processOrder(orderId) {
try {
const order = await dbQuery('SELECT * FROM orders WHERE id = ?', [orderId]);
if (!order) throw new Error('Order not found');
const inStock = await inventoryCheck(order.productId);
if (!inStock) throw new Error('Out of stock');
const receipt = await paymentCharge(order.customerId, order.amount);
await dbQuery('UPDATE orders SET status = ? WHERE id = ?', ['paid', orderId]);
await emailSend(order.customerEmail, receipt);
return receipt;
} catch (err) {
throw err; // re-throw with original message intact
}
}
The logic is identical. The readability is night-and-day. And unlike the original, every error path now bubbles up correctly.
The Anti-Prompt
Convert this code to async/await.
[PASTE CODE]
Why it fails: This prompt leaves every important decision to the model. In practice, you'll get a response that:
- Converts sequential and parallel operations to sequential
awaitchains (a known performance pitfall highlighted in multiple 2024–2025 async/await guides) - Drops subtle error paths that existed only as
if (err) return callback(err)branches - Adds
.then()chains next toawaitcalls, mixing styles that confuse the next developer - Changes the function to return
undefinedinstead of the original value because the model forgot to addreturn
The anti-prompt treats async/await conversion as a purely syntactic transformation. It isn't. It is a behavior-preserving refactor, and it requires explicit constraints.
Python Variation
For Python codebases migrating from threading or raw callbacks to asyncio:
Convert the following Python function from synchronous/threading-based I/O to async/await using asyncio.
Requirements:
1. Replace blocking I/O (requests, open, time.sleep) with their async equivalents (httpx.AsyncClient, aiofiles, asyncio.sleep).
2. Use asyncio.gather() for operations that can run concurrently.
3. Preserve all exception types and messages — do not convert exceptions to generic RuntimeError.
4. Add a single try/except block at the function boundary.
5. Do not change the function's public interface.
Here is the function:
[PASTE YOUR CODE]
Variations
For legacy fs callbacks in Node.js 14+: Add "Prefer the fs/promises API (require('fs/promises')) over util.promisify where the built-in promise API exists" — this eliminates wrapper boilerplate entirely for file operations.
For class methods: Add "Ensure promisified methods are bound to the class instance to preserve the correct 'this' context" — this catches the most common bug in class-based Node.js refactors.
For large files: Add "Convert one function at a time and leave the rest unchanged so the PR diff stays reviewable" — incremental migration is almost always safer than a full-file rewrite.
For TypeScript: Add "Update the return type annotation to Promise<OriginalReturnType> and add the async keyword to the type declaration" — keeps your types in sync with your implementation.
Action Items
- Find one callback-heavy function in your codebase (search for
function(err,orcb)patterns in Node.js projects) - Run it through today's prompt before writing a single line manually
- After conversion, check: does every original
if (err) return callback(err)have a corresponding path that throws or re-throws in the async version? - If the function calls multiple independent async operations sequentially, ask the AI to refactor those to
Promise.all()in a follow-up prompt - Run your existing tests — if the behavior is preserved, the refactor is safe
Ask The Guild
What is the hairiest callback-based function you have ever had to modernize — and what was the gotcha that almost sunk you? Share your war story (and ideally the before/after diff) in the community thread. Bonus points if it involved this context loss on a promisified class method.