Asynchronous programming is the backbone of modern JavaScript. Whether you’re fetching data from API’s, reading files or managing timers, understanding async patterns is crucial. this blog explore fundamental async concepts through practical examples from real-world assigments.
Part 1: Understanding Promises
What is a Promise?
A Promise is an object representing the eventual completion (or failure) of an asynchronous operation and its resulting value. it’s a cleaner alternative to traditional callbacks.
A Promise has three states:
- Pending: Initial state, operation hasn’t completed yet
- Resolved: Operation completed successfully
- Rejected: Operation failed
Concept 1: Creating Simple Deplay with delay(ms, value)
The problem:
function delay(ms, value) {
return new Promise((resolve) => {
setTimeout(() => {
resolve(value);
}, ms);
});
}
What we learn:
- Promise Constructor: Takes a function with resolve and reject parameters
- setTimeout integration: Combining timers with Promises
- Delayed Execution: How to pause and resume code flow
Real-World Use:
// Wait 2 seconds, then log a message
delay(2000, "Hello!").then(msg => console.log(msg));
Key Takeaway: Promises wrap asynchrnous operations(like timmers) into a manageable interface.
Concept 2: Promise Timeout Pattern with fetchWithTimeout(url, ms)
The Problem:
// Problem Description – fetchWithTimeout(url, ms)
// You are required to write a function named fetchWithTimeout that accepts a URL and a time limit in milliseconds.
// The function must return a Promise that attempts to fetch data from the given URL.
// If the request completes within the specified time, the Promise resolves with the fetched data.
// If the operation exceeds the time limit, the Promise rejects with the message "Request Timed Out".
function fetchWithTimeout(url, ms) {
return new Promise((resolve, reject) => {
let timeoutId = setTimeout(() => {
reject("Request Timed Out");
}, ms);
fetch(url)
.then(response => {
clearTimeout(timeoutId);
resolve(response);
})
.catch(error => {
clearTimeout(timeoutId);
reject(error);
});
});
}
function fetchWithTimeoutClean(url, ms) {
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => {
reject("Request Timed Out");
}, ms);
});
return Promise.race([fetch(url), timeoutPromise]);
}
module.exports = { fetchWithTimeout, fetchWithTimeoutClean };
What We Learn:
- Promise.race(): Returns the result of whichever Promise settles first
- Timeout implementation: Creating a race condition to enforce time limits
- Error Handling: Rejecting with meaningful error messages
How it works:
- Create a timeout Promise that rejects after ms milliseconds
- Race the actual fetch against the timeout
- If fetch completes first -> success
- If timeout completes first -> rejection with timeout message
Real-World Use:
fetchWithTimeout('https://api.example.com/data', 5000)
.then(response => console.log("Data received:", response))
.catch(error => console.log("Error:", error));
Key Takeaway: Promise.race() is perfect for implementing timeout logic and competitive async operations.
Concept 3: Rejecting Promises with rejectAfter(ms)
The Problem:
function rejectAfter(ms) {
return new Promise((resolve, reject) => {
setTimeout(() => reject(new Error(`Rejected after ${ms}ms`)), ms);
});
}
What We Learn:
- Controlled Rejection: Purposefully rejecting a Promise with an error
- Error Messages: Including context in error objects
- Promise Lifecycle: Understanding reject as a first-class operation
Real-World Use:
rejectAfter(3000)
.catch(error => console.log("Task failed:", error.message));
// Output after 3 seconds: "Task failed: Rejected after 3000ms"
Key Takeaway: Rejection is as important as resolution; always provide meaningful error messages.
Part 2: Higher-Order Functions & Closures
Concept 4: Executing Functions Once with once(fn)
The Problem:
function once(fn) {
let called = false;
let result;
return function(...arg) {
if(!called) {
called = true;
result = fn(...arg);
}
return result;
};
}
What We Learn:
- Higher-Order Functions: Functions that return other functions
- Closures: Inner functions capturing outer scope variables
- State Preservation: Maintaining state across multiple calls
- Memoization Pattern: Caching results to avoid re-execution
How It Works:
once()returns a new function- The returned function uses closure to access
calledandresult - First call: executes
fn, setscalled = true, stores result - Subsequent calls: returns cached result without re-executing
Real-World Use:
const fetchData = once(async () => {
const response = await fetch('/api/data');
return response.json();
});
fetchData(); // Executes the fetch
fetchData(); // Returns same Promise (no new fetch)
fetchData(); // Returns same Promise (no new fetch)
Key takeaway: Higher-order functions and clousers enable elegant control flow pattern without global state.
Concept 5: Retrying Failed Operations with retryOnce(fn)
The Problem:
function retryOnce(fn) {
return async function(...args) {
try {
return await fn(...args);
} catch (error) {
try {
return await fn(...args);
} catch (retryError) {
throw retryError;
}
}
};
}
What We Learn:
- Error Handling: Nested try-catch blocks for multiple attempts
- Retry Logic: Automatic retrying on failure
- Async/Await: Cleaner syntax than Promise chains for complex flows
- Error Propagation: When to catch vs. when to throw
How It Works:
- First attempt in outer try block
- If it fails, retry once in nested try block
- If retry also fails, throw the error
- If either succeeds, return the result
Real-World Use:
const unreliableAPI = retryOnce(async () => {
const response = await fetch('/unreliable-api');
if (!response.ok) throw new Error('API failed');
return response.json();
});
unreliableAPI(); // Tries once, retries once if fails
Key Takeaway: Retry logic is essential for handling transient failures in distributed systems.
Part 3: Callback Pattern Conversion
Concept 6: Converting Callbacks to Promises with promisify(fn)
The Problem:
function promisify(fn) {
return function(...args) {
return new Promise((resolve, reject) => {
fn(...args, (error, data) => {
if(error) {
reject(error)
} else {
resolve(data);
}
})
})
}
}
What we Learn:
- Callback Pattern: Understanding error-first callbacks(Node.js conversion)
- Promise Wrapper: Converting callback-based APIs to Promises
- Error-First Convention: Why Node.js uses
(err, data)callback signature - Bridge Pattern: Adapting old code to new patterns
How it works:
- Takes a callback-based function
- Returns a function that wraps it in a Promise
- Calls the original function with a custom callback
- If callback receives error → reject the Promise
- If callback receives data → resolve the Promise
Real-World Use:
// Old callback style (Node.js file system)
fs.readFile('file.txt', (error, data) => {
if (error) console.error(error);
else console.log(data);
});
// Convert to Promise
const readFilePromise = promisify(fs.readFile);
readFilePromise('file.txt')
.then(data => console.log(data))
.catch(error => console.error(error));
Key Takeaway: Promisification is essential for modernizing legacy callback-based codebases.
Part 4: Ensuring Async Behavior
Concept 7: Guaranteeing Promises with ensureAsync(fn)
The Problem:
function ensureAsync(fn) {
return async (...args) => {
return await fn(...args);
};
}
What We Learn:
- Async Functions: Always return Promises, even with synchronous code
- Mixed Function Types: Handling both sync and async functions uniformly
- Error Wrapping: Synchronous errors become rejected Promises
- Consistency: Providing a uniform interface regardless of function type
How It Works:
- Wraps any function in an async function
- Async functions always return Promises
- If
fnis sync and returns value → Promise resolves with value - If
fnis sync and throws → Promise rejects with error - If
fnis async → Promise returns as-is
Real-World Use:
const syncFn = (x) => x * 2;
const asyncFn = async (x) => x * 2;
const wrappedSync = ensureAsync(syncFn);
const wrappedAsync = ensureAsync(asyncFn);
wrappedSync(5).then(result => console.log(result)); // Promise: 10
wrappedAsync(5).then(result => console.log(result)); // Promise: 10
Key Takeaway: ensureAsync normalizes functions, ensuring they always return Promises for consistent handling.
Part 5: Simple Timer Functions
Concept 8: Pausing Execution with sleep(ms)
The Problem:
function sleep(millis) {
return new Promise((resolve) => {
setTimeout(resolve, millis);
});
}
What We Learn:
- Promise Resolution: Using Promise to return control after delay
- Event Loop: How
setTimeoutand Promises interact - Async Sequencing: Controlling execution order with delays
Real-World Use:
async function delayedSequence() {
console.log("Starting...");
await sleep(2000);
console.log("2 seconds later!");
await sleep(1000);
console.log("1 more second!");
}
Key Takeaway: sleep() with async/await is cleaner than nested callbacks for sequential delays.
Key Concepts Summary
| Concept | Purpose | Pattern |
|---|---|---|
| Promises | Manage async operations | Constructor, .then(), .catch() |
| Promise.race() | First to settle wins | Timeout implementation |
| Higher-Order Functions | Functions returning functions | once(), retry() |
| Closures | Capture outer scope | State preservation |
| Promisification | Convert callbacks → Promises | Bridge old/new code |
| Async/Await | Cleaner Promise syntax | Try-catch error handling |
| Error Handling | Manage failures | Try-catch, .catch() chains |
Best Practices
- Always Handle Errors: Every Promise should have error handling
promise.catch(error => console.error("Error:", error));
- Use Async/Await Over .then(): More readable for sequential operations
// Good
const result = await fetch(url);
// Avoid
fetch(url).then(r => doSomething(r));
- Create Meaningful Error Messages: Help with debugging
reject(new Error(`Failed after ${attempts} attempts`));
- Don’t Mix Paradigms: Choose either async/await OR Promise chains consistently
// Avoid mixing
async function bad() {
return await promise.then(x => x).catch(e => e);
}
- Understand When to Use Promise.race() vs Promise.all():
race(): First to settleall(): All must succeed