JavaScript for Backend
Learn async/await, Promises, and error handling - the patterns that power every Node.js application.
The One Thing That Makes Backend JS Different#
Frontend JavaScript waits for clicks and keyboard input. Backend JavaScript waits for something else entirely: slow operations.
Reading a file? Slow. Querying a database? Slow. Making an HTTP request? Slow. At least compared to how fast your code runs.
If JavaScript waited around for every slow operation to finish, your server would be useless. Imagine a restaurant where the waiter takes your order, goes to the kitchen, watches the chef cook, waits for the food, brings it to you, and only THEN takes the next table's order. That restaurant would serve maybe 10 customers per hour.
Real restaurants don't work that way. The waiter takes your order, hands it to the kitchen, and immediately goes to the next table. When food is ready, they deliver it.
That's exactly how backend JavaScript works. And the tools that make this possible are Promises and async/await.
Starting Simple: Callbacks#
Before Promises existed, JavaScript used callbacks - functions that run when something finishes.
// "When the file is done reading, run this function"
fs.readFile('data.txt', (error, data) => {
if (error) {
console.log('Something went wrong:', error);
return;
}
console.log('File contents:', data);
});
console.log('This runs BEFORE the file is done reading!');
Output:
This runs BEFORE the file is done reading!
File contents: [contents of data.txt]
See how the code doesn't wait? It starts reading the file, moves on, and comes back when ready.
The Problem with Callbacks#
Callbacks work, but they get ugly fast. What if you need to read a file, then use its contents to query a database, then send an email?
// This is called "Callback Hell" - and it's as bad as it sounds
fs.readFile('config.json', (err, config) => {
if (err) return handleError(err);
database.connect(config, (err, db) => {
if (err) return handleError(err);
db.query('SELECT * FROM users', (err, users) => {
if (err) return handleError(err);
sendEmail(users[0].email, (err, result) => {
if (err) return handleError(err);
console.log('Done!');
// Imagine 5 more levels of this...
});
});
});
});
This pyramid of doom is hard to read, hard to debug, and easy to mess up. There had to be a better way.
Enter Promises#
A Promise is exactly what it sounds like - a promise that a value will exist in the future.
Think of it like ordering food at a counter. You order, they give you a receipt with a number. That receipt is a promise that food will come. You don't have food yet, but you have something that represents future food.
// A Promise is in one of three states:
// 1. Pending - still waiting (you're holding the receipt)
// 2. Fulfilled - success! (your food arrived)
// 3. Rejected - something went wrong (they're out of ingredients)
Creating a Promise#
function orderCoffee(type) {
return new Promise((resolve, reject) => {
console.log(`Making a ${type}...`);
setTimeout(() => {
if (type === 'decaf') {
reject(new Error("We don't serve decaf here"));
} else {
resolve({ type, temperature: 'hot' });
}
}, 2000); // Takes 2 seconds to make
});
}
Using a Promise#
orderCoffee('latte')
.then(coffee => {
console.log('Got my coffee:', coffee);
})
.catch(error => {
console.log('Order failed:', error.message);
});
console.log('Waiting for coffee...');
Output:
Making a latte...
Waiting for coffee...
(2 seconds pass)
Got my coffee: { type: 'latte', temperature: 'hot' }
The code doesn't freeze while waiting. It keeps running and comes back when the coffee is ready.
Chaining Promises#
Here's where Promises shine. Each .then() returns a new Promise, so you can chain them:
orderCoffee('espresso')
.then(coffee => {
console.log('Got coffee, adding milk...');
return addMilk(coffee);
})
.then(latte => {
console.log('Added milk, adding sugar...');
return addSugar(latte);
})
.then(sweetLatte => {
console.log('Perfect!', sweetLatte);
})
.catch(error => {
// Catches errors from ANY step above
console.log('Something went wrong:', error.message);
});
Much flatter than callback hell, right?
async/await: Promises Made Beautiful#
Promises were a huge improvement, but the .then() chains can still get messy. In 2017, JavaScript got async/await - syntactic sugar that makes Promises look like normal, synchronous code.
// With .then() chains
function getUser(id) {
return fetchUser(id)
.then(user => fetchPosts(user.id))
.then(posts => fetchComments(posts[0].id))
.then(comments => ({ user, posts, comments }));
}
// With async/await - same thing, much cleaner
async function getUser(id) {
const user = await fetchUser(id);
const posts = await fetchPosts(user.id);
const comments = await fetchComments(posts[0].id);
return { user, posts, comments };
}
The await keyword pauses the function until the Promise resolves. But here's the magic: it only pauses that function, not your entire program. Other code keeps running.
Two Rules
awaitcan only be used inside anasyncfunction- An
asyncfunction always returns a Promise, even if you return a plain value
Your First async Function#
async function greet(name) {
return `Hello, ${name}!`;
}
// Even though we returned a string, it's wrapped in a Promise
greet('Sarah').then(message => console.log(message));
// Output: Hello, Sarah!
Real-World Example#
import fs from 'fs/promises';
async function loadConfig() {
// This reads like synchronous code, but it's not blocking anything
const data = await fs.readFile('config.json', 'utf8');
const config = JSON.parse(data);
console.log('Config loaded:', config);
return config;
}
loadConfig();
console.log('This prints BEFORE config is loaded!');
Error Handling: The Make-or-Break Skill#
Here's a harsh truth: unhandled errors crash Node.js applications. Not "show an error message" - actually crash. Your server goes down.
This is where many beginners fail. You MUST handle errors.
try/catch with async/await#
async function getUser(id) {
try {
const user = await database.findUser(id);
const posts = await database.findPosts(user.id);
return { user, posts };
} catch (error) {
console.error('Something went wrong:', error.message);
// Now you can decide what to do:
// - Return a default value
// - Throw a different error
// - Log and continue
throw error; // Re-throw if you want the caller to handle it
}
}
The Error Object#
When something goes wrong, you get an Error object with useful information:
try {
await riskyOperation();
} catch (error) {
console.log(error.name); // 'Error', 'TypeError', etc.
console.log(error.message); // What went wrong
console.log(error.stack); // Where it happened (super useful for debugging)
}
Creating Custom Errors#
Generic errors aren't helpful. "Error: Something went wrong" tells you nothing. Create specific errors:
class NotFoundError extends Error {
constructor(resource) {
super(`${resource} not found`);
this.name = 'NotFoundError';
this.statusCode = 404;
}
}
class ValidationError extends Error {
constructor(message, field) {
super(message);
this.name = 'ValidationError';
this.field = field;
this.statusCode = 400;
}
}
// Usage
async function getUser(id) {
const user = await database.findUser(id);
if (!user) {
throw new NotFoundError('User');
}
return user;
}
Now when you catch errors, you know exactly what went wrong:
try {
const user = await getUser(123);
} catch (error) {
if (error instanceof NotFoundError) {
// User doesn't exist - show 404 page
} else if (error instanceof ValidationError) {
// Bad input - show form errors
} else {
// Something unexpected - log and show generic error
}
}
Running Things in Parallel#
Here's a performance tip that can make your code 3-10x faster.
The Slow Way (Sequential)#
async function getUserProfile(userId) {
const user = await fetchUser(userId); // Takes 100ms
const posts = await fetchPosts(userId); // Takes 100ms
const followers = await fetchFollowers(userId); // Takes 100ms
return { user, posts, followers };
// Total time: 300ms (each waits for the previous)
}
Each await waits for the previous one to finish. But these three requests don't depend on each other - they could run at the same time!
The Fast Way (Parallel)#
async function getUserProfile(userId) {
const [user, posts, followers] = await Promise.all([
fetchUser(userId), // Starts immediately
fetchPosts(userId), // Starts immediately
fetchFollowers(userId), // Starts immediately
]);
return { user, posts, followers };
// Total time: ~100ms (all run at the same time)
}
Promise.all() starts all Promises immediately and waits for all of them to finish. Since they run in parallel, you only wait for the slowest one.
When NOT to Use Promise.all
If one operation depends on another's result, you must run them sequentially:
const user = await fetchUser(userId);
const posts = await fetchPosts(user.blogId); // Needs user.blogId
Promise.allSettled: When Some Failures Are OK#
Promise.all fails fast - if ANY Promise rejects, the whole thing fails. Sometimes you want all results, even if some fail:
async function sendNotifications(userIds) {
const results = await Promise.allSettled(
userIds.map(id => sendNotification(id))
);
// Check each result
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`User ${userIds[index]}: sent`);
} else {
console.log(`User ${userIds[index]}: failed - ${result.reason.message}`);
}
});
}
Practical Patterns You'll Use Daily#
Retry Logic#
External APIs fail sometimes. Instead of giving up immediately, retry a few times:
async function fetchWithRetry(url, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(url);
return await response.json();
} catch (error) {
console.log(`Attempt ${attempt} failed: ${error.message}`);
if (attempt === maxRetries) {
throw new Error(`Failed after ${maxRetries} attempts`);
}
// Wait a bit before retrying (exponential backoff)
await sleep(1000 * attempt);
}
}
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
Timeout Wrapper#
Don't let slow operations hang forever:
function withTimeout(promise, ms, message = 'Operation timed out') {
const timeout = new Promise((_, reject) => {
setTimeout(() => reject(new Error(message)), ms);
});
return Promise.race([promise, timeout]);
}
// Usage
try {
const data = await withTimeout(
fetchSlowAPI(),
5000,
'API took too long to respond'
);
} catch (error) {
console.log(error.message); // 'API took too long to respond'
}
Debounce for Search#
When a user types in a search box, you don't want to hit the API on every keystroke:
function debounce(fn, delay) {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delay);
};
}
// Only searches after user stops typing for 300ms
const debouncedSearch = debounce(async (query) => {
const results = await searchAPI(query);
displayResults(results);
}, 300);
Key Takeaways#
Backend JavaScript is all about juggling slow operations without freezing your application. The tools are simple once you understand them:
- Promises represent future values - use
.then()and.catch() - async/await makes Promises look like normal code - cleaner and easier to debug
- try/catch handles errors - unhandled errors crash your app
- Promise.all runs things in parallel - huge performance boost
- Custom errors make debugging easier - know exactly what went wrong
The Golden Rule
When in doubt, use async/await with try/catch. It's readable, it's debuggable, and it handles errors properly. Your future self will thank you.
Ready to level up your skills?
Explore more guides and tutorials to deepen your understanding and become a better developer.