Making Functions Asynchronous in JavaScript Without async/await or Promises

Hi, I'm a mern stack developer based in Chattogram, Bangladesh. I became interested in web development during my college years. I started learning JavaScript after finishing HTML & CSS. The more I learned about JavaScript, the more I fell in love with it. JavaScript has always been my favorite programming language. It eventually led me to become a Mern stack developer.
When most people hear asynchronous JavaScript, they immediately think of async/await or Promises. But JavaScript had ways to schedule asynchronous work long before Promises came along. The async keyword is just syntactic sugar around Promises, but the event loop has always given us tools to delay execution, handle events, and avoid blocking the main thread.
In this post, we’ll explore how to make a normal function “asynchronous-like” without using async/await or Promises.
1. The Event Loop to the Rescue
JavaScript runs in a single thread, but it avoids freezing the browser (or Node.js process) by offloading tasks to the event loop. The event loop manages two main kinds of queues:
Macrotasks → things like
setTimeout, I/O callbacks.Microtasks → things like
queueMicrotask,process.nextTick, and Promise callbacks.
By pushing a function into one of these queues, you make it run “later,” i.e. asynchronously.
2. setTimeout: The Classic
function asyncLike(fn) {
setTimeout(fn, 0);
}
console.log("A");
asyncLike(() => console.log("B"));
console.log("C");
// Output: A, C, B
Here, fn is executed on the next event loop tick. It won’t block the main thread, but you also lose the ability to directly return values.
3. setImmediate (Node.js Only)
In Node.js, setImmediate works similarly to setTimeout(fn, 0) but is optimized for scheduling work after I/O operations.
setImmediate(() => {
console.log("Runs after current I/O events");
});
4. process.nextTick (Node.js Only)
process.nextTick queues work in the microtask queue, meaning it will run before other I/O tasks.
process.nextTick(() => {
console.log("This runs before timers and I/O callbacks");
});
5. queueMicrotask: A Modern Primitive
Available in browsers and Node.js, queueMicrotask is the cleanest way to schedule a function as a microtask:
queueMicrotask(() => {
console.log("Runs right after current code, before rendering");
});
This is basically what Promises use under the hood.
6. Event Emitters: Async via Pub/Sub
Sometimes you don’t just want to run a function later—you want to notify multiple listeners when it finishes. Enter EventEmitters (in Node.js).
const EventEmitter = require('events');
function asyncifyWithEmitter(fn) {
const emitter = new EventEmitter();
setTimeout(() => {
try {
const result = fn();
emitter.emit('done', result);
} catch (err) {
emitter.emit('error', err);
}
}, 0);
return emitter;
}
// Usage
const emitter = asyncifyWithEmitter(() => {
console.log("Work happening...");
return 42;
});
emitter.on('done', (value) => {
console.log("Got result:", value);
});
emitter.on('error', (err) => {
console.error("Something went wrong:", err);
});
console.log("After scheduling");
Output:
After scheduling
Work happening...
Got result: 42
This style mirrors how older Node.js APIs (like file I/O) worked before Promises were standardized.
7. Putting It All Together
So far, we’ve seen multiple ways to async-ify code without Promises:
setTimeout(fn, 0)→ macrotask, runs “later.”setImmediate(fn)→ Node.js, optimized post-I/O scheduling.process.nextTick(fn)→ Node.js, microtask, runs very soon.queueMicrotask(fn)→ modern microtask primitive.EventEmitter→ async notification system for multiple listeners.
Each has trade-offs: setTimeout is easy but imprecise, microtasks (queueMicrotask, nextTick) give tighter control, and EventEmitters are great for broadcasting results to many consumers.



