In the beginning was the word, and the word was async.
Let’s imagine the following scenario: you are making a burger. To do this, you need to fry the patties, toast the buns, spread the sauce, add the cheese, and assemble the burger. Only after all these steps are complete you can finally eat it.
How would this look in code?
function makeBurgerSync() {
console.log("--- Starting preparation (Synchronous) ---");
// 1. Fry the patty (blocking operation)
const patty = fryPattySync();
console.log("Patty is ready");
// 2. Toast the buns (only after the patty is done)
const bun = toastBunSync();
console.log("Buns are toasted");
// 3. Spread the sauce
const bunWithSauce = applySauce(bun);
// 4. Add the cheese
const readyBun = addCheese(bunWithSauce);
// 5. Assemble and eat
const burger = assemble(readyBun, patty);
eat(burger);
console.log("Burger eaten! Total time: 7 minutes");
}
While we are frying the patties or toasting the buns, the main thread of our application is occupied and cannot handle other requests.
However, in our diner, we want to achieve better efficiency and, whenever possible, avoid idle waiting for blocking operations. We could start frying the patty and, without waiting for it to finish, move on to the buns.
How do we achieve this?
To solve this, the concept of callback functions was invented — we pass a function as an argument to another function so that we are notified when the operation is complete.
// Frying function that accepts a callback
function fryPatty(onReadyCallback) {
console.log("Patty is on the grill...");
// Simulating an asynchronous frying process (e.g., 2 seconds)
setTimeout(() => {
const patty = "🔥 Juicy Patty";
// Work is done — we call the function that was passed to us
onReadyCallback(patty);
}, 2000);
}
// Usage: passing "what to do next" directly as an argument
fryPatty((finishedPatty) => {
console.log("Hooray! " + finishedPatty + " is ready, we can now assemble the burger.");
});
console.log("The Chef is free to chop vegetables while the patty is frying...");
Unfortunately, this concept turned out to be less than ideal, as it led to the following situation:
function makeBurgerAsyncCallback() {
console.log("The Chef has taken the order and is free to handle new tickets!");
// 1. Start frying the patty, pass the instruction (callback)
fryPatty((patty) => {
console.log("Patty is ready. Starting on the bun immediately...");
// 2. Patty is done, now start toasting the bun
toastBun((bun) => {
console.log("Bun is toasted. Time for the sauce...");
// 3. Bun is ready, spread the sauce
applySauce(bun, (bunWithSauce) => {
console.log("Sauce is on. Adding cheese...");
// 4. Add the cheese
addCheese(bunWithSauce, (readyBun) => {
console.log("Cheese is added. Final assembly...");
// 5. Assemble the result
const burger = assemble(readyBun, patty);
eat(burger);
});
});
});
});
console.log("The Chef can already take the next order while the first burger is frying.");
}
Or using a scheme:

You have to admit, it looks a bit monstrous. The nesting level of callback functions is way too deep. This situation is widely known as “callback hell.”
How would error handling even look in code like this?
function makeBurgerDetailedErrorHandling() {
fryPatty((err, patty) => {
if (err) {
// Different logic for different types of patty errors
if (err.type === 'BURNT') {
console.error("The chef overcooked the meat. Calling cleaning services...");
} else if (err.type === 'NO_MEAT') {
console.error("The warehouse is empty. Ordering a delivery immediately!");
}
return;
}
toastBun((err, bun) => {
if (err) {
// Attempting to handle the bun error
if (err.code === 404) {
console.error("No buns left. Offering the customer a salad instead...");
} else {
console.error("The toaster is broken. Calling a technician.");
}
return;
}
applySauce(bun, (err, bunWithSauce) => {
if (err) {
console.error("Out of sauce. Spreading mayo by default.");
// Example of "fallback" logic inside the callback:
continuePreparation(bun, patty);
return;
}
// ... and so on to infinity
});
});
});
}
Several more problems emerge immediately:
- Code Bloating: We only added a couple of checks, but the code has doubled. It’s become impossible to read.
- Obscured Business Logic: The actual actions (fry, toast, apply) get lost in the visual noise of error checks. It’s difficult for a reader to grasp what the function is actually doing.
- Fragility: Forgetting just one
returninside anif (err)block means the program will try to toast the bun without a patty, leading to a hard-to-trace bug. If we forget to propagate or handle an error at any level, the chain will simply “hang” or crash without explanation. - DRY Violation (Code Duplication): At every single level, we are writing the same pattern:
if (err) { ... return; }. It’s tedious, it litters the logic, and it’s prone to human error.
We can’t go on like this. Something has to change. We want to reclaim the linearity and convenient error handling inherent in sequential, synchronous programming.
This brings us to the concept of the Promise.
// Each step now returns a Promise
makeBurgerWithPromises()
.then(patty => {
console.log("Patty is ready");
return toastBun(patty); // Passing the result down the chain
})
.then(bun => {
console.log("Bun is ready");
return applySauce(bun);
})
.then(bunWithSauce => {
return addCheese(bunWithSauce);
})
.then(finalBun => {
assembleAndEat(finalBun);
})
.catch(err => {
// ONE handler for the entire chain!
console.error("Something went wrong in the kitchen:", err);
});
A Promise is the first serious attempt by programmers to turn control flow into data. We stopped building mazes of functions and started building pipelines of objects.
In Callback Hell, we are forced to stay vigilant at every inch of our code. Promises, on the other hand, allow us to describe the “Happy Path” while gathering all errors in one central place at the very end. This is the first step toward monadic error handling, where an error isn’t a catastrophe that breaks the code, but simply an alternative data path in the flow.
Here is how that same piece of code would look in Java:
import java.util.concurrent.CompletableFuture;
public void makeBurgerCompletableFuture() {
System.out.println("Order received. Starting asynchronous preparation...");
// 1. Start frying the patty in parallel
CompletableFuture<Patty> pattyTask = CompletableFuture.supplyAsync(() -> {
return fryPatty(); // Executes in ForkJoinPool.commonPool()
});
// 2. Prepare the bun in parallel
CompletableFuture<Bun> bunTask = CompletableFuture.supplyAsync(() -> toastBun())
// As soon as the bun is ready, apply the sauce IMMEDIATELY (chaining within the task)
.thenApply(bun -> applySauce(bun));
// 3. Synchronization point (similar to Promise.all)
// When BOTH tasks are complete, we combine them
pattyTask.thenCombine(bunTask, (patty, bun) -> {
System.out.println("Ingredients are ready. Assembling!");
return assemble(patty, bun);
})
// 4. Final action
.thenAccept(burger -> eat(burger))
// 5. Declarative error handling (Catch-all)
.exceptionally(ex -> {
System.err.println("Fire in the kitchen: " + ex.getMessage());
return null;
});
System.out.println("Chef's main thread is free for new orders.");
}
How are Promises implemented under the hood? Let’s try to reinvent them.
At its core, every Promise relies on three things:
- State:
pending,fulfilled, orrejected. - Value: The data (result) or the error.
- Handlers: A list of functions waiting to be executed (those very
.then()calls).
class MyPromise {
constructor(executor) {
this.state = 'pending';
this.value = undefined;
this.handlers = [];
const resolve = (result) => {
if (this.state !== 'pending') return;
this.state = 'fulfilled';
this.value = result;
this.handlers.forEach(h => h());
};
const reject = (error) => {
if (this.state !== 'pending') return;
this.state = 'rejected';
this.value = error;
this.handlers.forEach(h => h());
};
try {
executor(resolve, reject);
} catch (e) {
reject(e);
}
}
then(onFulfilled) {
// We return a NEW promise. This is the heart of chaining.
return new MyPromise((resolve, reject) => {
// This function wraps the logic and decides when to resolve the NEXT promise
const handle = () => {
try {
if (this.state === 'fulfilled') {
// 1. Take the result of the current promise
// 2. Pass it through the transformation function (onFulfilled)
const transformedValue = onFulfilled(this.value);
// 3. The transformation result becomes the value of the NEW promise
resolve(transformedValue);
} else if (this.state === 'rejected') {
reject(this.value);
}
} catch (e) {
reject(e);
}
};
if (this.state === 'pending') {
this.handlers.push(handle);
} else {
// If the promise is already settled, execute the handler immediately
handle();
}
});
}
}
Every .then() call creates a new Promise object that “subscribes” to the completion of the previous one. This is the essence of monadic binding: the result of the previous computation becomes the input data for the next “wrapper.”
So, in this article, we’ve journeyed from making burgers to understanding the concept of callbacks, explored the problems they bring, and finally arrived at Promises and their internal implementation.
I hope you found it interesting. Stay tuned — in the next part, we’ll dive into the transition from Promises to structured concurrency.
