Mid-Level Frontend Interview: Deep JavaScript Fundamentals — Event Loop, Closures and the Trick Questions
Bài này có bản tiếng Việt. Đọc bản tiếng Việt →
Nguyen Hai Trinh • Senior Software Engineer @ SmileViet
The JavaScript fundamentals round is where a lot of mid-level candidates fail, even with two or three years of React and Vue on the CV. It’s not that they don’t know JavaScript. They know enough to write code that runs. They just can’t explain why it runs the way it does. And interviewers rarely ask for definitions. They hand you ten lines of code and ask “what does this print, in what order, and why?”
These are the topics that keep coming back in mid-level rounds: the event loop, closures, this, prototypes, and async/await with the Promise combinators. Each section follows the same rhythm: the question, the “knows it but shallow” answer, the answer with real depth, then the follow-up trap. I ran every output in here on Node.js. No guessing.
Where Mid Differs From Senior in This Round
Expectations first, because a lot of people prepare for the wrong thing.
What’s expected of a Mid:
- Predict the correct output of code that mixes
setTimeout, Promises andasync/await. - Explain closures with real examples (loops, debounce, factories), not by reciting a definition.
- Know that
thisis decided at call time, not at definition time (except for arrow functions). - Tell
Promise.all,allSettled,raceandanyapart and pick the right one. - Fix bugs like stale values,
forEach+await, and a missingreturnin.thenon their own.
What’s expected on top of that for a Senior:
- Explain why there are two queues, microtasks and macrotasks, and the consequence: microtasks can starve rendering.
- Point out operational risks: unhandled rejections, memory leaks from closures holding references, long tasks blocking the main thread.
- Design cancellation (
AbortController), concurrency limits, and retry with backoff. - Know which code behaves differently between the browser and Node (the order of
setImmediateandprocess.nextTick).
The usual failure looks like this: the candidate gets the “what” right, then freezes the moment the interviewer changes one small detail and asks “why”.
1. The Event Loop: Log Order
The interviewer asks: “What order does this code print in?”
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
queueMicrotask(() => console.log("4"));
(async () => {
console.log("5");
await null;
console.log("6");
})();
console.log("7");
The “knows it but shallow” answer: “Synchronous code runs first, then the Promise, then setTimeout.” Right direction. But once queueMicrotask and await are in the mix, they start guessing, and 6 ends up in the wrong place.
The answer with real depth: the model I always draw on paper has three parts: the call stack, the microtask queue and the macrotask queue (task queue). One turn of the loop goes like this. Run the current synchronous script to the end. That script is itself a macrotask. Then drain the microtask queue until it’s empty. In a browser, rendering happens next. Only then does the loop pick up the next macrotask.
Applied to the question:
- The synchronous part prints
1,5(the part of the async function beforeawaitruns synchronously) and7. - Microtasks are queued in registration order: the
.thencallback (prints3),queueMicrotask(prints4), and the continuation afterawait null(prints6). - Only after microtasks are done do we get to the macrotask:
setTimeoutprints2.
Result: 1 5 7 3 4 6 2.
Here is one full turn of the loop:
flowchart TD
A["Run the whole synchronous script<br/>prints 1, 5, 7"] --> B["Drain the microtask queue<br/>prints 3, 4, then 6"]
B --> C{"Anything left in the microtask queue?"}
C -->|yes| B
C -->|empty| D["The browser may render"]
D --> E["Take the next macrotask<br/>setTimeout prints 2"]
E --> A
await x really splits the function into two halves. The second half is a microtask, queued once x has resolved. Mid-level candidates often think await “pauses the whole program”. It doesn’t. It pauses only that function, and the call stack returns to the caller, which carries on.
Follow-up trap: “What if a microtask calls queueMicrotask again, and keeps doing that recursively?” The microtask queue has to be empty before the event loop moves on. So an endless chain of microtasks blocks rendering and every macrotask along with it. The page freezes, and there isn’t a single while loop in sight. Microtasks aren’t “fair” the way macrotasks are.
2. async/await and the “Hidden” Ticks
The interviewer asks: “A slightly harder one. What does this print?”
async function a1() {
console.log("a1 start");
await a2();
console.log("a1 end");
}
async function a2() {
console.log("a2");
}
console.log("script start");
setTimeout(() => console.log("timeout"), 0);
a1();
new Promise((resolve) => {
console.log("p1");
resolve();
})
.then(() => console.log("then1"))
.then(() => console.log("then2"));
console.log("script end");
Actual output:
script start
a1 start
a2
p1
script end
a1 end
then1
then2
timeout
The answer with real depth: the executor of new Promise runs synchronously, so p1 prints right away in the synchronous part. a2() also runs synchronously and returns an already-resolved native promise. In a modern engine, await on a native promise costs exactly one tick (V8 optimized this around 2018; before that it cost three). await a2() is registered before .then(() => console.log("then1")), so a1 end comes before then1. then2 has to wait for then1 to finish before it gets queued. It always comes after.
Follow-up trap: “What if a2 returns a thenable instead of a native promise?” A thenable is an object with a .then. The engine has to call that .then in a separate microtask, which costs extra ticks, so a1 end can drop behind then1. You don’t need to memorize the exact tick count. The interviewer just wants to see that you know the order depends on whether the value is a native promise or a thenable. In real code you shouldn’t let behavior depend on tick counts, but you should be able to read it.
3. Closures: Loops, var and let
The interviewer asks: “How do these two loops differ?”
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log("var", i), 0);
}
for (let j = 0; j < 3; j++) {
setTimeout(() => console.log("let", j), 0);
}
Output: var 3, var 3, var 3, then let 0, let 1, let 2.
The “knows it but shallow” answer: “var has function scope and let has block scope.” True, but it doesn’t answer the real question: why do the callbacks see 3?
The answer with real depth: a closure is a function that keeps a reference to the environment (scope) where it was created, not the value at the moment of creation. With var, all three callbacks point at the same single i in function scope. By the time setTimeout fires, the loop has finished and i === 3. With let, the spec says each iteration creates a fresh binding for j, copying the value from the previous iteration before incrementing, so each callback has its own j.
The difference between the two is in what holds the variable:
flowchart TD V["var i in function scope"] --> V1["three callbacks point at one variable i"] V1 --> V2["setTimeout fires after the loop ends: all three print 3"] L["let j: a new binding on every iteration"] --> L1["each callback keeps its own binding"] L1 --> L2["prints 0, 1, 2 in turn"]
The old-school fix, which you’ll still find in legacy codebases, is an IIFE:
for (var k = 0; k < 3; k++) {
((captured) => {
setTimeout(() => console.log("iife", captured), 0);
})(k);
}
In practice, closures show up less in loops and more in debounce, memoize and factories:
function debounce(fn, wait) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), wait);
};
}
function createCounter() {
let count = 0;
return {
inc: () => ++count,
get: () => count,
};
}
timer and count are truly “private” variables. There’s no way to touch them except through the returned functions.
Follow-up trap: “Can closures cause memory leaks?” A closure by itself doesn’t leak. It keeps alive every variable it references, along with those of any closures sharing its scope. Common examples: an event listener registered in a component and never removed, or a callback holding a large data array from its parent scope. The fix: remove listeners in cleanup, set big variables to null when you’re done, and measure with a heap snapshot in DevTools instead of guessing.
4. this: Decided at Call Time
The interviewer asks: “What does each call below print?”
"use strict";
const user = {
name: "An",
regular() {
return this?.name;
},
arrowInside() {
const inner = () => this.name;
return inner();
},
};
const detached = user.regular;
console.log(user.regular()); // "An"
console.log(detached()); // undefined
console.log(user.arrowInside()); // "An"
const bound = detached.bind({ name: "Bình" });
console.log(bound()); // "Bình"
console.log(bound.call({ name: "Chi" })); // "Bình"
The “knows it but shallow” answer: “this is the object before the dot.” That rule works for user.regular(). At detached() it falls apart.
The answer with real depth: for a regular function, this is decided by how it’s called (the call site). The priority order I usually present: new > explicit binding (call, apply, bind) > method call (obj.fn()) > plain call (fn()). A plain call in strict mode gives this === undefined; in sloppy mode you get globalThis. That’s why I put "use strict" in the example and spell out the difference, so nobody has to argue about it.
An arrow function has no this of its own. It takes this from the surrounding scope at definition time (lexical this), so inner() inside arrowInside uses the method’s this. bind creates a new function with this locked in. Calling .call on a bound function can’t change this either, which is why the last line still prints “Bình”.
The bug you’ll hit most in practice is losing this when you pass a method as a callback:
class Timer {
seconds = 0;
tickBroken() {
this.seconds++;
}
tick = () => {
this.seconds++;
};
}
const t = new Timer();
setInterval(t.tick, 1000); // fine: the arrow field keeps this
// setInterval(t.tickBroken, 1000); // bug: this is no longer t
Follow-up trap: “Why not use arrow functions for every method in an object literal?” An arrow has no this of its own, so const o = { name: "x", hi: () => this.name } doesn’t point at o. Arrows also can’t be constructors, and they have no arguments. And with arrow class fields, every instance gets its own copy of the function. That uses more memory than a method on the prototype, and it’s harder to mock in tests.
5. Prototypes: What a “class” Really Is
The interviewer asks: “How is a JavaScript class different from a function constructor? Where do the methods live?”
class Animal {
constructor(name) {
this.name = name;
}
speak() {
return `${this.name} makes a sound`;
}
}
class Dog extends Animal {
speak() {
return `${super.speak()} - woof`;
}
}
const d = new Dog("Mực");
console.log(Object.hasOwn(d, "name")); // true
console.log(Object.hasOwn(d, "speak")); // false
console.log(Object.getPrototypeOf(d) === Dog.prototype); // true
console.log(Object.getPrototypeOf(Dog.prototype) === Animal.prototype); // true
console.log(typeof Dog); // "function"
The “knows it but shallow” answer: “class is syntax sugar over prototypes.” Short, half right, and the interviewer follows up immediately.
The answer with real depth: every object has an internal [[Prototype]] link, accessible through Object.getPrototypeOf. When you read d.speak, the engine looks on d itself first. If it isn’t there, it follows the [[Prototype]] chain to Dog.prototype, finds speak, and stops. Data (name) is an own property of the instance. Methods live on the prototype, so every instance shares them. Dog.prototype has its own [[Prototype]] pointing to Animal.prototype, and the chain ends at Object.prototype and then null.
Here’s that chain walked end to end:
flowchart LR D["d — own property: name"] --> DP["Dog.prototype — speak"] DP --> AP["Animal.prototype"] AP --> OP["Object.prototype"] OP --> NU["null"]
“Syntax sugar” is only partly true. Class differs from a function constructor in a few real ways: no callable-style hoisting (it sits in the temporal dead zone), it always runs in strict mode, you can’t call it without new, and its methods aren’t enumerable. And extends also sets up the prototype chain for the constructors themselves (Object.getPrototypeOf(Dog) === Animal), so static members are inherited.
Building a prototype chain without classes:
const base = {
greet() {
return `Xin chào, ${this.name}`;
},
};
const child = Object.create(base, {
name: { value: "Lan", enumerable: true },
});
console.log(child.greet()); // "Xin chào, Lan"
console.log(Object.hasOwn(child, "greet")); // false
Follow-up trap: “Why does for...in sometimes print properties that don’t belong to the object?” Because for...in also walks enumerable properties inherited along the prototype chain. Use Object.keys, Object.entries, or filter with Object.hasOwn. Bonus point: don’t modify built-in prototypes. Monkey patching Array.prototype changes the behavior of every other piece of code in the app, third-party libraries included.
6. async/await in Practice: Three Classic Mistakes
The interviewer asks: “What’s wrong with this code?”
async function sendAll(users) {
users.forEach(async (u) => {
await sendEmail(u);
});
console.log("done");
}
The “knows it but shallow” answer: “I think it prints done first.” That’s the right symptom. The cause and the fix, no idea.
The answer with real depth: forEach ignores the callback’s return value, so it doesn’t wait for the promises the async callbacks return. sendAll finishes immediately. done prints before any email is sent, and errors from sendEmail become unhandled rejections because nobody catches them. How you fix it depends on what you intend:
// Sequential: each email waits for the previous one
async function sendSequential(users) {
for (const u of users) {
await sendEmail(u);
}
}
// Parallel: everything runs at once, wait for all
async function sendParallel(users) {
await Promise.all(users.map((u) => sendEmail(u)));
}
Second mistake: accidentally serializing work that should run in parallel.
// Slow: total time = a + b
const a = await fetchA();
const b = await fetchB();
// Faster when the two are independent: total time = max(a, b)
const [a2, b2] = await Promise.all([fetchA(), fetchB()]);
Third mistake: forgetting return, or forgetting await inside try/catch.
async function load() {
try {
return fetchData(); // no await: the error is NOT caught here
} catch (e) {
console.error("never runs for async errors", e);
}
}
async function loadFixed() {
try {
return await fetchData(); // with await: catch picks up the rejection
} catch (e) {
console.error("caught", e);
throw e;
}
}
Follow-up trap: “Is it fine to parallelize 5,000 requests with Promise.all(ids.map(fetchOne))?” No. Firing 5,000 requests at once hits the browser’s connection limit, the server’s rate limit, or eats all your memory. The senior answer is a concurrency limit: batch the work, or write a small pool.
async function mapLimit(items, limit, worker) {
const results = new Array(items.length);
let next = 0;
async function run() {
while (next < items.length) {
const index = next++;
results[index] = await worker(items[index], index);
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, run));
return results;
}
JavaScript is single-threaded, so next++ doesn’t race between workers. Context switches only happen at await.
7. Promise Combinators: Pick the Right Tool
The interviewer asks: “What’s the difference between all, allSettled, race and any? Give example output.”
const ok = (v, ms) => new Promise((r) => setTimeout(() => r(v), ms));
const fail = (m, ms) => new Promise((_, rej) => setTimeout(() => rej(new Error(m)), ms));
const tasks = () => [ok("A", 30), fail("B failed", 10), ok("C", 20)];
Promise.all(tasks()).catch((e) => console.log("all:", e.message));
// all: B failed
Promise.allSettled(tasks()).then((rs) =>
console.log("allSettled:", rs.map((r) => r.status))
);
// allSettled: [ 'fulfilled', 'rejected', 'fulfilled' ]
Promise.race(tasks()).catch((e) => console.log("race:", e.message));
// race: B failed
Promise.any(tasks()).then((v) => console.log("any:", v));
// any: C
The answer with real depth:
| Combinator | Resolves when | Rejects when |
|---|---|---|
Promise.all | all succeed, returns an array in input order | one promise rejects (fail-fast, returns the first error) |
Promise.allSettled | all are done, whether they succeeded or failed | never rejects |
Promise.race | the first to finish is fulfilled | the first to finish is rejected |
Promise.any | the first to succeed | all reject (throws AggregateError) |
Choose by intent. Need every result to be valid before moving on? all. Need partial reporting, like loading several widgets and showing the error only in the one that failed? allSettled. Need a timeout? race. Need the fastest mirror that’s still alive? any.
Timeout via race is a common extension question, and there’s a trap in it:
function withTimeout(promise, ms) {
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`Timeout sau ${ms}ms`)), ms);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
Follow-up trap: “When a promise loses the race, does the other one get cancelled?” No. race only picks a result. The real request keeps running and keeps using bandwidth. To actually cancel, you have to pass an AbortSignal to fetch and call controller.abort() when time runs out. Cancellation belongs to the execution layer, not to Promises.
8. Extra Practice: Guess Before You Look at the Answer
I use these two exercises to tell understanding from memorization. Guess on paper first, then run them.
Exercise A:
console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve()
.then(() => {
console.log("C");
setTimeout(() => console.log("D"), 0);
})
.then(() => console.log("E"));
setTimeout(() => console.log("F"), 0);
console.log("G");
Answer: A G C E B F D. The synchronous part prints A, G. Then the microtasks: C runs, and right then the macrotask D is registered at the end of the queue, then E. Macrotasks run in registration order: B, F, and D comes last because it was registered last, even though its delay is also 0.
Exercise B:
const p = new Promise((resolve, reject) => {
resolve("first");
reject(new Error("ignored"));
resolve("second");
});
p.then((v) => {
console.log(v);
return Promise.reject(new Error("boom"));
})
.catch((e) => {
console.log(e.message);
return "recovered";
})
.then((v) => console.log(v))
.finally(() => console.log("finally"));
Answer: first, boom, recovered, finally. A promise settles only once, so the reject call and the second resolve are ignored. .catch returns a plain value, so the chain goes back to the fulfilled state. finally receives no value and doesn’t change the chain’s result, unless it throws or returns a rejected promise.
Why Mid-Level Candidates Who “Know” It Still Fail
Across many rounds, the same causes keep repeating:
- Memorizing definitions without running code. Everyone can say “a closure is a function that remembers its scope”. But when the question swaps
varforletor adds oneawait, someone who has never run the code and predicted the result beforehand freezes. The best habit: write the snippet, write your prediction on paper, run it, and if you were wrong, find the mechanism. - Guessing instead of tracing the model. Strong candidates draw three columns, stack, microtask, macrotask, and fill them in step by step. Weak ones “eyeball it” and commit. Interviewers watch the thought process more than the final answer.
- Stopping at a one-layer answer. The follow-up always comes: “what if we change X?” Prepare by asking yourself after every conclusion: what would make this conclusion wrong?
- Not tying it to a real situation. Asked “what are closures for?”, “to remember variables” loses to “I used one to debounce a search box, and I hit a bug once because I forgot
clearTimeout”. - Panicking when you don’t know. Saying “I’m not sure about this part, here’s how I’d reason about it” scores better than a wild guess. Reasoning from first principles is exactly what’s being evaluated.
One-Week Review Checklist
- Redraw the event loop model and explain it to someone non-technical (if you can, you really understand it).
- Do at least ten guess-the-output exercises, writing your prediction first each time. Mix
setTimeout,then,async/await,queueMicrotask. - Hand-write
debounce,throttle,memoize, abindpolyfill, and aPromise.allpolyfill. Every one of them touches closures,thisor Promises. - Read the async sections of MDN and the related spec to understand job ordering, instead of only reading summary blog posts.
- For each topic, prepare a real bug story from your own projects. A bug you actually hit and fixed is more convincing than ten definitions.
A mid-level interview doesn’t ask you to memorize the ECMAScript spec. It asks for a mental model accurate enough to predict what code does, and honest enough to say where you’re unsure. Three columns on paper, stack, microtask, macrotask, are enough to start.
Keep reading
- Tech Lead Interview: The Leadership Round, Technical Decisions and MentoringInterview & Career
- Frontend Lead Interview: Frontend System Design With Micro-Frontends, Monorepos and Trade-offsInterview & Career
- Senior Fullstack Interview: Auth & Security with Sessions, JWT, CSRF, XSS and OAuth2Interview & Career
Need a developer for a contract or freelance project?
I take on fullstack projects — Go backends, React/Next.js frontends, or both ends. Send a few lines about the scope and timeline and I'll reply within 48 hours.