Most async code works fine until one step fails halfway through a workflow. Then you get double charges, missing data, or silent corruption.
These patterns fix that.
1. Replace Sequential Awaits With Compensated Steps
Sequential code looks clean but breaks on partial failure.
Before
async function processOrder(orderId: string) {
const order = await fetchOrder(orderId)
const payment = await chargeCustomer(order.customerId, order.total)
const shipment = await createShipment(order.items, order.address)
return { order, payment, shipment }
}
If shipment fails, payment is already done. No rollback.
After
async function processOrder(orderId: string) {
const order = await fetchOrder(orderId)
let payment
try {
payment = await chargeCustomer(order.customerId, order.total)
} catch {
throw new Error('PAYMENT_FAILED')
}
try {
const shipment = await createShipment(order.items, order.address)
return { order, payment, shipment }
} catch {
await refundPayment(payment.id).catch(() => {
logger.fatal('REFUND FAILED', { orderId })
})
throw new Error('SHIPMENT_FAILED')
}
}
You explicitly define rollback logic. This is what real systems do.
2. Start Independent Promises Early
Most developers accidentally serialize independent work.
Before
async function loadData(userId: string) {
const user = await fetchUser(userId)
const analytics = await fetchAnalytics(userId)
return { user, analytics }
}
Total time = sum of both calls.
After
async function loadData(userId: string) {
const analyticsPromise = fetchAnalytics(userId)
const user = await fetchUser(userId)
const analytics = await analyticsPromise
return { user, analytics }
}
You save latency without changing logic. This pattern shows up in senior interviews .
3. Guard Multi-Call Flows With Promise.allSettled
Dashboards should not fail entirely because one API is down.
Before
const [users, orders, stats] = await Promise.all([
fetchUsers(),
fetchOrders(),
fetchStats()
])
One failure kills everything.
After
const results = await Promise.allSettled([
fetchUsers(),
fetchOrders(),
fetchStats()
])
const [users, orders, stats] = results.map(r =>
r.status === 'fulfilled' ? r.value : null
)
Now partial data renders. Failures become observable, not catastrophic.
4. Retry Only Transient Errors With Backoff
Retrying everything is worse than not retrying.
Before
await fetch('/api/payment')
One network hiccup breaks the flow.
After
async function retry(fn, attempts = 3) {
for (let i = 0; i < attempts; i++) {
try {
return await fn()
} catch (e) {
if (e.status && e.status < 500) throw e
await new Promise(r => setTimeout(r, 2 ** i * 100))
}
}
throw new Error('FAILED_AFTER_RETRIES')
}
await retry(() => fetch('/api/payment'))
You retry only when it makes sense. This avoids hammering APIs.
This becomes critical when dealing with flaky external systems like described in the Node.js memory leak debugging scenarios where retries amplify system pressure if done wrong.
5. Cancel Stale Requests With AbortController
Race conditions are one of the most common production bugs.
Before
useEffect(() => {
fetch(`/api/search?q=${query}`)
.then(r => r.json())
.then(setResults)
}, [query])
Old responses overwrite new ones.
After
useEffect(() => {
const controller = new AbortController()
fetch(`/api/search?q=${query}`, {
signal: controller.signal
})
.then(r => r.json())
.then(setResults)
.catch(err => {
if (err.name !== 'AbortError') throw err
})
return () => controller.abort()
}, [query])
Now only the latest request wins. No stale UI.
6. Limit Concurrency Instead of Flooding APIs
Firing 100 requests at once will get you rate-limited or banned.
Before
await Promise.all(ids.map(id => fetchItem(id)))
Unbounded concurrency.
After
async function limit(tasks, concurrency) {
const results = []
let i = 0
async function worker() {
while (i < tasks.length) {
const current = i++
results[current] = await tasks[current]()
}
}
await Promise.all(
Array.from({ length: concurrency }, worker)
)
return results
}
const tasks = ids.map(id => () => fetchItem(id))
await limit(tasks, 5)
You control throughput. Systems stay stable under load.
Closing
Take one of your existing async flows and add compensation or cancellation today. That alone removes an entire class of production bugs.
If your code assumes the happy path, it is already broken.
Top comments (0)