Is your JavaScript code a pyramid of callbacks? Every AJAX call adds another level of indentation, and by the third level you can't follow the logic anymore. We see this all the time in projects that come to us: asynchronous code written as if it were 2010. And the problem isn't just cosmetic – more nested callbacks mean more silent errors, more trouble handling failures.
Here at Meteora Web, we work with JavaScript every day – from WooCommerce customizations to Laravel/Vue platforms. Handling asynchrony cleanly isn't a luxury: it's what separates a maintainable application from one that needs to be rewritten after three months. This guide shows you how to move from callbacks to Promises and then to async/await, with robust error handling.
From callback hell to Promises: why it changes everything
Imagine you need to load a user's data, then their order, then the product details. With callbacks you'd write:
getUser(id, function(user) {
getOrder(user.id, function(order) {
getProduct(order.productId, function(product) {
console.log(product.name);
}, function(err) {
console.error('Product error', err);
});
}, function(err) {
console.error('Order error', err);
});
}, function(err) {
console.error('User error', err);
});
Three levels of callbacks, three separate error handlers, and if you forget one of those function(err) the error goes unnoticed. It's not just ugly: it's fragile.
Promises solve the problem by giving you a linear chain. Each async operation returns a Promise object that can be resolved or rejected.
getUser(id)
.then(user => getOrder(user.id))
.then(order => getProduct(order.productId))
.then(product => console.log(product.name))
.catch(err => console.error('Error in chain', err));
A single catch at the end handles any error in the chain. If getUser fails, it jumps straight to catch. It's linear, predictable, maintainable.
Creating a Promise from scratch
Understanding how to create a Promise helps you use libraries and APIs with confidence. Here's a real-world example: simulating an API call with fetch, but wrapped in a Promise for more control:
function fetchWithTimeout(url, timeout = 5000) {
return new Promise((resolve, reject) => {
const controller = new AbortController();
const timer = setTimeout(() => {
controller.abort();
reject(new Error('Request timed out'));
}, timeout);
fetch(url, { signal: controller.signal })
.then(response => {
clearTimeout(timer);
if (!response.ok) {
reject(new Error(`HTTP ${response.status}`));
} else {
resolve(response.json());
}
})
.catch(err => {
clearTimeout(timer);
reject(err);
});
});
}
This function returns a Promise: if the request succeeds, resolve with the JSON data; otherwise reject with an error. The timeout prevents the request from hanging forever – a common problem in apps that forget to handle network timeouts.
async/await: async code that reads like sync
Promises made code readable, but the then/catch syntax can still be verbose. With async/await you write code that reads as if it were synchronous, but remains asynchronous.
async function loadProductData(userId) {
try {
const user = await getUser(userId);
const order = await getOrder(user.id);
const product = await getProduct(order.productId);
console.log(product.name);
} catch (error) {
console.error('Error loading data', error);
}
}
Each await pauses the function's execution until the Promise is resolved or rejected. The function itself is declared async, so it always returns a Promise. This allows you to use try/catch to handle errors – exactly as you would with synchronous code.
Common mistake: forgetting try/catch
Many developers write:
async function load() {
const data = await fetch('/api'); // if fetch fails, an unhandled exception is thrown
// ...
}
If fetch throws an error (network down, malformed JSON), the exception is not caught and the Promise returned by load() is rejected without anyone handling it. Result: silent error or app crash. Always wrap await in a try/catch, or chain a .catch() if you prefer.
Advanced error handling: retry, fallback, and cancellation
In our work on e-commerce platforms and web applications, error handling goes beyond simple logging. A network error may be temporary; a failed request can be retried. Here's a generic retry function:
async function fetchWithRetry(url, retries = 3, delay = 1000) {
for (let i = 0; i < retries; i++) {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
if (i === retries - 1) throw error; // last attempt, rethrow
console.warn(`Attempt ${i+1} failed. Retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
This function tries up to 3 times, with a 1-second interval. If it ultimately fails, the original error is propagated – and can be handled by the caller. We use similar patterns in our projects for API calls to external providers (payment gateways, shipping services) that may temporarily be unreachable.
Fallback to local data
Another useful pattern: if a call fails, show cached data or a fallback. Example:
async function loadProductList() {
try {
const data = await fetchFromAPI('/products');
cache.set('products', data);
return data;
} catch {
const cached = cache.get('products');
if (cached) {
console.warn('Using cached data');
return cached;
}
throw new Error('No data available');
}
}
This way the user doesn't see a blank page if the server is momentarily unreachable. We used this for an e-commerce client whose catalog was served by a slow ERP: the local cache allowed users to browse products while data updated in the background.
Cancelling a running Promise
Sometimes you need to abort an ongoing async request – for example, if the user navigates away. With AbortController you can do that:
async function searchProducts(query, signal) {
const response = await fetch(`/api/search?q=${query}`, { signal });
return response.json();
}
// Usage
const controller = new AbortController();
searchProducts('shoes', controller.signal).catch(err => {
if (err.name === 'AbortError') {
console.log('Request cancelled');
} else {
console.error('Error', err);
}
});
// When you need to cancel:
controller.abort();
This prevents late responses from overwriting more recent data – a common problem in search UIs.
Complete example: asynchronous contact form handling
Let's put it all together. A contact form that sends data to an API, handles network errors, and provides user feedback.
class ContactForm {
constructor(formElement) {
this.form = formElement;
this.submitBtn = formElement.querySelector('[type="submit"]');
this.form.addEventListener('submit', this.handleSubmit.bind(this));
}
async handleSubmit(event) {
event.preventDefault();
this.setLoading(true);
try {
const formData = new FormData(this.form);
const data = await this.sendData(formData);
this.showSuccess('Message sent successfully!');
this.form.reset();
} catch (error) {
this.showError(error.message || 'Unexpected error. Please try again later.');
} finally {
this.setLoading(false);
}
}
async sendData(formData) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
try {
const response = await fetch('/api/contact', {
method: 'POST',
body: formData,
signal: controller.signal
});
clearTimeout(timeout);
if (!response.ok) {
const errorBody = await response.json().catch(() => null);
throw new Error(errorBody?.message || `Server error (${response.status})`);
}
return response.json();
} catch (error) {
clearTimeout(timeout);
if (error.name === 'AbortError') {
throw new Error('Request too slow. Please try again.');
}
throw error;
}
}
setLoading(loading) {
this.submitBtn.disabled = loading;
this.submitBtn.textContent = loading ? 'Sending...' : 'Send message';
}
showSuccess(msg) { /* show green message */ }
showError(msg) { /* show red message */ }
}
Notice: finally ensures the button is re-enabled even on error. The timeout with AbortController avoids infinite waits. And extracting the error from the response body allows showing meaningful messages (e.g., "invalid email field") instead of a generic "HTTP 400".
What to do now — actionable steps
- Rewrite your callbacks into Promises. If you still have nested callback code, convert it to
.then()chains. MDN's guide on Promises is the perfect starting point. - Adopt async/await for readability. Every function using
awaitmust beasyncand must handle errors withtry/catchor a final.catch(). - Add retries and timeouts. Network calls are unreliable. Implement at least a timeout (using
AbortController) and, for critical operations, a retry with backoff. - Don't leave Promises hanging. If an
asyncfunction can throw, make sure the caller handles the rejection. Otherwise you get an unhandled rejection that can crash Node.js apps or remain silent in the browser. - Test error scenarios. Simulate timeouts, HTTP errors, malformed JSON. If your async code isn't tested with real errors, you don't know if it holds up.
Here at Meteora Web, we apply these patterns every day – from WooCommerce AJAX requests to Laravel/Vue platform APIs. Well-managed asynchrony is not just about elegant code: it's about reliability for your users and lower maintenance costs for you.
Sponsored Protocol