Promise chains are easier to read than try-catch async/await
Async/await was supposed to make asynchronous code read like synchronous code. It delivered on that, right up until you need error handling. Then you reach for try-catch, and you're back to nesting, scope hoisting, and control flow that takes careful reading to follow.
Promise chains never had this problem. The error path was always part of the chain.
The happy path
For a simple two-step operation (fetch a user, then fetch their orders), the two styles look almost the same:
// async/await
async function getUserOrders(id) {
try {
const res = await fetch(`/api/users/${id}`);
const user = await res.json();
const ordersRes = await fetch(`/api/orders?userId=${user.id}`);
return await ordersRes.json();
} catch (err) {
handleError(err);
}
}
// promise chain
function getUserOrders(id) {
return fetch(`/api/users/${id}`)
.then(res => res.json())
.then(user => fetch(`/api/orders?userId=${user.id}`))
.then(res => res.json())
.catch(handleError);
}
Still close. The async/await version reads top to bottom. The promise chain reads as a pipeline, where each .then() takes the previous result and produces the next input. At this level of complexity, pick whichever you prefer.
Add error handling
Now suppose the user fetch and the orders fetch should fail differently. The user fetch failing is a 404 you want to handle gracefully. The orders fetch failing should retry.
// async/await
async function getUserOrders(id) {
let user;
try {
const res = await fetch(`/api/users/${id}`);
user = await res.json();
} catch (err) {
return null;
}
let orders;
try {
const ordersRes = await fetch(`/api/orders?userId=${user.id}`);
orders = await ordersRes.json();
} catch (err) {
orders = await retryFetchOrders(user.id);
}
return { user, orders };
}
// promise chain
function getUserOrders(id) {
return fetch(`/api/users/${id}`)
.then(res => res.json())
.catch(() => null)
.then(user => {
if (!user) return null;
return fetch(`/api/orders?userId=${user.id}`)
.then(res => res.json())
.catch(() => retryFetchOrders(user.id))
.then(orders => ({ user, orders }));
});
}
The async/await version needed two separate try-catch blocks and two let declarations hoisted above their respective blocks. user is declared on one line, assigned four lines later inside a try block, and used ten lines later in a completely different try block. You have to read the whole function to trace how user flows through it.
The promise chain version isn't perfect here either, but the data flow is visible in the chain structure. Each .catch() sits right next to the operation it handles. You can see which error handler belongs to which step without scanning the whole function.
The const problem
Try-catch creates a block scope. If you declare const user inside a try block, it's not accessible after the block ends. So you hoist the declaration to let, assign inside the block, and use it later.
In a function with three or four awaited steps that each need their own error handling, you end up with:
async function processCheckout(cartId) {
let cart;
let inventory;
let payment;
let confirmation;
try {
cart = await fetchCart(cartId);
} catch (err) {
return { error: 'cart_not_found' };
}
try {
inventory = await checkInventory(cart.items);
} catch (err) {
return { error: 'inventory_check_failed' };
}
try {
payment = await chargeCard(cart.total);
} catch (err) {
await releaseInventory(cart.items);
return { error: 'payment_failed' };
}
try {
confirmation = await createOrder(cart, payment);
} catch (err) {
await refundPayment(payment.id);
return { error: 'order_creation_failed' };
}
return confirmation;
}
Four let declarations at the top, four try-catch blocks, each variable assigned far from where it's declared. The function reads like a fill-in-the-blank template: declare everything upfront, assign inside scattered blocks, then trust that every variable got a value before it's used.
The promise chain equivalent:
function processCheckout(cartId) {
return fetchCart(cartId)
.catch(() => Promise.reject({ error: 'cart_not_found' }))
.then(cart =>
checkInventory(cart.items)
.catch(() => Promise.reject({ error: 'inventory_check_failed' }))
.then(() => cart)
)
.then(cart =>
chargeCard(cart.total)
.catch(async () => {
await releaseInventory(cart.items);
return Promise.reject({ error: 'payment_failed' });
})
.then(payment => ({ cart, payment }))
)
.then(({ cart, payment }) =>
createOrder(cart, payment)
.catch(async () => {
await refundPayment(payment.id);
return Promise.reject({ error: 'order_creation_failed' });
})
);
}
No let declarations. Every value is a function parameter. Each step receives exactly what it needs from the previous step, and the data flow is explicit in the chain rather than implicit through shared mutable scope.
The tuple workaround
A common response to the const problem is a helper that returns [error, result] tuples:
async function processCheckout(cartId) {
const [cartErr, cart] = await tryCatch(fetchCart(cartId));
if (cartErr) return { error: 'cart_not_found' };
const [invErr] = await tryCatch(checkInventory(cart.items));
if (invErr) return { error: 'inventory_check_failed' };
const [payErr, payment] = await tryCatch(chargeCard(cart.total));
if (payErr) {
await releaseInventory(cart.items);
return { error: 'payment_failed' };
}
const [orderErr, confirmation] = await tryCatch(createOrder(cart, payment));
if (orderErr) {
await refundPayment(payment.id);
return { error: 'order_creation_failed' };
}
return confirmation;
}
This solves the let hoisting. You get const declarations, and each error check sits right after the operation that might fail. The Go-style if err pattern works well here.
The error checks are short and sit right next to each operation. For many codebases this is the right answer, and it's a clear improvement over raw try-catch.
The difference is structural. In the tuple version, the happy path and error handling are interleaved: operation, check, operation, check. You read them together. In a promise chain, the happy path is the chain of .then() calls, and error handling hangs off the side in .catch(). You can scan one without reading the other.
The tradeoff
Stack traces are harder to read when something goes wrong deep in a chain (though modern engines have narrowed this gap). Early returns are awkward: you can't return out of a chain the way you can bail out of an async function. If a chain gets deeply nested, you've recreated callback hell with different syntax. TypeScript infers types through async/await without annotation but sometimes needs explicit generics through long .then() chains.
The biggest limitation is conditional logic. "If the user is an admin, fetch permissions; otherwise fetch public config" is a plain if statement in async/await. In a promise chain, it means returning different promises from inside a .then() callback, and the nesting gets uncomfortable fast. Promise chains work best when the pipeline is linear.
This post is about that specific shape: a linear pipeline of async operations where each step might fail differently. For that shape, promise chains produce code with tighter scoping that makes the data flow between steps visible in the structure itself.
Wrapping up
Async/await is good. The problem is that try-catch, the only built-in mechanism for handling errors with await, reintroduces the noise that async/await was designed to eliminate. For async pipelines, .then().catch() keeps the error handling next to the operation, keeps variables scoped to where they're used, and reads as a data pipeline rather than a control flow maze.