Mid-level JavaScript interviews stop asking for definitions and start asking you to predict microtask order and implement debounce, bind, and memoize from scratch. Here's the real question set, attempt, then reveal.
NK
Nicanor Korir
Author
July 15, 2026
18 min read
JavaScriptInterviewIntermediateClosuresEvent LoopAsyncWeb Development
The jump from junior to mid-level interviews is where a lot of good engineers stumble, not because they lack the knowledge, but because the format changes and they prepare for the wrong thing.
At the junior level, interviewers ask "what does this print?" At the mid level, they ask two harder questions: "predict the exact order this async code runs in," and "now implement the utility you've been importing." You don't get to import { debounce } from 'lodash', you write it, live, and handle this and the edge cases while you talk.
I run these interviews now, and the pattern is consistent: candidates who've used closures and async/await for years but never had to reconstruct them freeze up. This article is built to fix that. Every question below is interactive, read it, solve it on paper or in your head first, then reveal the full answer. For the "implement X" ones, genuinely try to write the code before you peek. That's the muscle the interview tests.
Want to drill these against a timer instead of reading top to bottom? The interactive practice hub turns the whole series into flashcards with progress tracking.
What the mid-level round is really testing
A mid-level interview (usually 45-60 minutes in a shared editor) screens for depth and the ability to build, not just use:
Deep, applied understanding of closures, this, and prototypes, not the one-line definition.
Predicting microtask vs macrotask ordering in mixed async code.
Implementing everyday utilities from first principles, debounce, bind, memoize, a small event emitter.
Trade-off awareness, naming when a pattern helps and when it hurts, before you're asked.
The strongest signal you can send: reason about the call stack, the microtask queue, and the macrotask queue explicitly, and name the trade-off in your own solution before the interviewer probes for it.
Closures, beyond "a function inside a function"
Every mid-level interview probes closures, and the shallow answer gets caught immediately. Expect the classic loop bug, and expect a follow-up about why the variables stay alive.
Q1
Closures★★★Live coding
What does this log? Then fix it to print 0, 1, 2, first with a one-word change, then without let.
JavaScript
javascript
1
2
3
#closures
#var
#let
#event-loop
It logs 3, 3, 3. var i is function-scoped, so all three callbacks close over the samei. The loop finishes (leaving i === 3) before any timeout fires, so each logs 3.
Fix 1, change `var` to `let`.let is block-scoped and, specially, gets a fresh binding per iteration, so each callback closes over its own i: 0, 1, 2.
Fix 2, without `let`, create a new scope per iteration with an IIFE (or by passing i as a setTimeout argument) so each callback captures its own copy:
js
js
1
2
3
Follow-ups they may ask
Why does `let` create a new binding each iteration but `var` doesn't?
Would an arrow vs regular function inside the loop change anything here?
Q2
Closures★★★Technical screen
Beyond 'a function that remembers its scope', what actually keeps a closure's variables alive, and when does that become a memory problem?
#closures
#encapsulation
#memory
Mechanically: a function value carries a hidden reference to the lexical environment (the scope record) it was created in. As long as that function is reachable, the whole environment it closed over stays reachable too, so the garbage collector can't reclaim those variables. That's why the counter's count survives after the outer function returns.
It becomes a leak when a long-lived closure keeps a large thing alive unnecessarily, e.g. an event handler that closes over a big DOM subtree or array, and is never removed. The fix is to null out references you no longer need, remove listeners, and avoid closing over more than you use. Engines optimise by only retaining variables the closure actually references, but you can still pin large objects by accident.
Follow-ups they may ask
How would `WeakMap` or `WeakRef` help avoid this kind of retention?
The event loop & async ordering
This is the mid-level topic. If you take one thing from this article, make it the microtask-vs-macrotask rule, it's asked in some form in the majority of interviews.
Q3
Event loop★★★Live coding
Predict the exact output order. This is the classic microtask-vs-macrotask question.
JavaScript
javascript
1
2
3
4
#event-loop
#microtask
#promises
#settimeout
Output: A, D, C, B.
Step by step:
console.log('A') runs synchronously → A.
setTimeout(..., 0) schedules its callback on the macrotask (task) queue.
Promise.resolve().then(...) schedules its callback on the microtask queue.
console.log('D') runs synchronously → D.
The call stack is now empty. The event loop drains the entire microtask queue first → C.
Only then does it take one macrotask → B.
The rule that wins this question: after each task, the engine empties all microtasks (Promise callbacks, queueMicrotask, await continuations) before rendering or taking the next macrotask (setTimeout, I/O). Microtasks always beat timers.
Follow-ups they may ask
Where does `await` fit, is the code after an `await` a microtask?
Can microtasks starve the event loop? How?
Q4
Async★★★Live coding
What does async/await actually desugar to? And what does this print?
JavaScript
javascript
1
2
3
4
5
6
7
8
#async-await
#promises
#microtask
An async function always returns a Promise. await x roughly means: evaluate x, wrap it with Promise.resolve, and schedule the rest of the function as a .then callback, i.e. a microtask. Everything up to the first await runs synchronously.
So the output is 0, 1, 3, 2:
console.log(0) → 0.
f() runs synchronously up to the await: console.log(1) → 1. The await suspends and queues the continuation as a microtask.
Control returns to the caller: console.log(3) → 3.
Stack empties; the microtask runs: console.log(2) → 2.
The insight: await doesn't block the thread, it yields, and the code after it is a microtask.
Follow-ups they may ask
How do you run two awaited async operations concurrently instead of sequentially?
Q5
Async★★★Technical screen
Compare Promise.all, Promise.allSettled, Promise.race, and Promise.any. When would you pick each?
#promise-all
#allSettled
#race
#any
All four take an iterable of promises and return one promise, but they resolve differently:
`all`, fulfils with an array of all results, but rejects as soon as any one rejects (fail-fast). Use when you need every result and any failure means abort.
`allSettled`, never rejects; fulfils with an array of {status, value|reason} once all settle. Use when you want every outcome regardless of individual failures (e.g. fire off N independent saves and report which failed).
`race`, settles as soon as the first promise settles, fulfilled or rejected. Use for timeouts: race a request against a setTimeout reject.
`any`, fulfils with the first fulfilment, ignoring rejections; only rejects (with an AggregateError) if they all reject. Use for redundancy: hit three mirrors, take the first that succeeds.
Follow-ups they may ask
How would you implement a timeout using `Promise.race`?
Does `Promise.all` run the promises in parallel or start them itself?
Implement it yourself: debounce, throttle & memoize
Here's where the interview gets hands-on. These three come up constantly because they test closures, this, and setTimeout all at once, and reveal whether you actually understand the tools you use daily.
Q6
Higher-order functions★★★Live coding
Implement debounce(fn, delay). It should postpone calling fn until delay ms have passed since the last call, and preserve this and arguments.
#debounce
#closures
#this
The core is a closure over a timer id: each call clears the pending timer and schedules a new one, so fn only fires once activity stops. Using a regular function plus fn.apply(this, args) preserves the caller's this (important when it's a method or event handler):
js
js
1
2
3
4
5
6
7
8
9
10
Note the returned function is a regular function, not an arrow, an arrow would capture this lexically and break method calls.
Follow-ups they may ask
How is this different from throttling?
How would you add a `cancel()` method, or a leading-edge option?
Q7
Higher-order functions★★★Live coding
Implement throttle(fn, interval) and explain how it differs from debounce.
#throttle
#performance
#closures
Debounce waits for a quiet period, it fires once after activity stops. Throttle guarantees at most one call per interval during continuous activity, it fires on a steady cadence. Use debounce for 'search as you type after they pause'; use throttle for scroll/resize/mousemove where you want regular updates but capped frequency.
A timestamp-based throttle:
js
js
1
2
3
4
5
6
7
8
9
10
Follow-ups they may ask
Your version drops the trailing call, how would you guarantee the final event still fires?
Q8
Higher-order functions★★★Live coding
Implement a memoize(fn) that caches results by arguments. What are its real-world limitations?
#memoization
#caching
#closures
A closure over a Map keyed by the arguments. A simple, honest version keys on a serialisation of the args:
js
js
1
2
3
4
5
6
7
8
9
10
Limitations to volunteer:JSON.stringify can't key on functions or non-serialisable args, is order-sensitive for object keys, and is slow for big inputs. The cache grows unbounded (a leak for long-lived functions), a real version needs an LRU bound. And it only helps pure functions; memoising something with side effects or time-varying results is a bug.
Follow-ups they may ask
What breaks with the `JSON.stringify` key approach?
How would you bound the cache size, or handle object arguments without stringifying?
this, call/apply/bind & prototypes
The this keyword and the prototype chain are the parts of JavaScript that feel like folklore until they click. Mid-level interviews check that they've clicked, often by having you implement bind yourself.
Q9
this★★★Live coding
What does each log print?
JavaScript
javascript
1
2
3
4
5
6
7
8
9
#this
#call
#apply
#bind
Prints 'A', then undefined, then undefined (assuming module scope where top-level this is undefined).
obj.regular(), called as a method, so this is obj → 'A'.
r(), the same function, but called bare. this is undefined in strict/module mode, so this?.name short-circuits to undefined. This is the classic 'lost this' when you detach a method.
obj.arrow(), arrow functions ignore how they're called and capture this lexically from where they were defined (module scope, where this is undefined). Making a method an arrow is a common mistake.
To keep this when detaching, use obj.regular.bind(obj).
Follow-ups they may ask
How would you fix `r()` to print 'A'?
Q10
this★★★Live coding
Implement Function.prototype.myBind, your own version of bind. It must fix this, support partial application, and behave reasonably when the bound function is called with new.
#bind
#this
#prototypes
bind returns a new function that, when called, invokes the original with a fixed this and any pre-filled arguments prepended. The subtle part interviewers probe: when the bound function is used as a constructor (new), the bound this should be ignored in favour of the freshly-created instance.
js
js
1
2
3
4
5
6
7
8
9
10
11
Follow-ups they may ask
Why do we set `boundFn.prototype`?
How do call and apply differ from bind?
Q11
Prototypes★★★Technical screen
Explain prototypal inheritance and the prototype chain. What happens when you read obj.toString()?
#prototypes
#inheritance
#this
Every object has an internal link ([[Prototype]], exposed as __proto__) to another object. When you read a property, the engine checks the object itself, then its prototype, then its prototype, walking the chain until it finds the property or hits null.
So obj.toString() isn't on your object, the engine walks up to Object.prototype, finds toString there, and calls it with this bound to obj. This is how methods are shared: instances made by a constructor all point their prototype at the same Constructor.prototype object, so one copy of each method serves every instance. ES6 class is syntax over exactly this mechanism.
Follow-ups they may ask
What's the difference between `__proto__` and `prototype`?
How does `class extends` set up the chain?
Objects, copying & data structures
Once you're comfortable with references (the junior topic), the mid-level version asks about deep vs shallow copying and the modern tools for each.
Q12
Objects★★★Technical screen
What's the difference between a shallow and a deep copy? Compare the ways to make each in modern JS.
#deep-copy
#shallow-copy
#structuredClone
A shallow copy duplicates the top level but shares nested references, { ...obj } and Object.assign({}, obj) copy the outer object, but a nested object inside is still the same object, so mutating copy.nested.x also changes the original.
A deep copy recursively duplicates everything, so the copy is fully independent. Options:
`structuredClone(obj)`, the modern built-in. Handles nested objects, arrays, Dates, Maps, Sets, and cyclic references. Can't clone functions or DOM nodes.
`JSON.parse(JSON.stringify(obj))`, quick but lossy: drops undefined, functions, and Symbols, turns Date into a string, and throws on cycles.
A hand-written recursive clone when you need custom handling.
Default to structuredClone for plain data.
Follow-ups they may ask
Why does the JSON trick lose a `Date`? What does it become?
Functional patterns & language features
Rounding out the round: currying, generators, the observer pattern, and module systems. These separate candidates who know ES6 syntax from those who understand why the features exist.
Q13
Functional★★★Live coding
Implement curry(fn) so that curry(add)(1)(2)(3) and curry(add)(1, 2)(3) both work for a 3-arg add.
#currying
#closures
#partial-application
Curry returns a function that keeps collecting arguments until it has at least as many as the original function's arity (fn.length), then invokes it. If it doesn't have enough yet, it returns another collector that remembers what's been gathered so far, a closure over accumulated args:
js
js
1
2
3
4
5
6
7
8
9
10
11
Follow-ups they may ask
How does `fn.length` behave with default parameters or rest args?
Q14
Patterns★★★Live coding
Implement a small event emitter with on, off, and emit. This is the observer pattern.
#event-emitter
#observer
#pub-sub
A Map from event name to a set of listeners. on registers, off removes, emit calls each listener with the payload. Returning an unsubscribe function from on is a nice touch interviewers like:
js
js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Follow-ups they may ask
What happens if a listener throws during `emit`? How would you isolate that?
Why use a `Set` rather than an array for the listeners?
Q15
Iterators★★★Technical screen
What are generators, and what's a real use case? Write a generator for an infinite id sequence.
#generators
#iterators
#lazy
A generator (function*) is a function you can pause and resume. Calling it returns an iterator; each next() runs until the next yield, hands back that value, and freezes execution, state is preserved between calls. That makes them ideal for lazy or infinite sequences (you never materialise the whole thing) and for modelling streams.
js
js
1
2
3
4
5
6
7
Historically they also underpinned async flow before async/await (which is essentially a generator that yields promises).
Follow-ups they may ask
How do generators relate to the iterator protocol and `for...of`?
Q16
Modules★★★Technical screen
Compare ES Modules and CommonJS. Why does the difference matter for tooling like tree-shaking?
#esm
#commonjs
#bundling
CommonJS (require/module.exports, Node's legacy system) is synchronous and dynamic, you can require conditionally at runtime, and exports are resolved as values when the call runs. ES Modules (import/export) are static: imports and exports are determined by the syntax before execution, and bindings are live (an imported value reflects later changes in the exporter).
That static structure is what enables tree-shaking: because a bundler can see exactly which exports are imported without running the code, it can drop unused ones. CommonJS's dynamic require can't be analysed as reliably, so dead-code elimination is weaker. ESM also supports async import() for code-splitting.
Follow-ups they may ask
What does dynamic `import()` return, and when would you use it?
How to prepare for the mid-level round
The knowledge is mostly there by now, the gap is usually fluency under a live editor. What works:
Implement the utilities from memory. Close the tab and write debounce, throttle, bind, curry, and a small event emitter cold. If you can't, you don't know them yet, recognising the code isn't the same as producing it.
Trace async snippets by hand. Draw the call stack, the microtask queue, and the macrotask queue as three columns, and step through. Do it until "microtasks drain before the next macrotask" is reflex.
Practise narrating trade-offs. After you solve something, say the sentence: "the trade-off here is..." Interviewers score judgment as much as correctness.
Green flags
You reason about the call stack, microtask, and macrotask queues by name.
Your debounce/bind correctly handles `this`, arguments, and edge cases.
You name the trade-off before the interviewer asks.
Red flags
Knowing async/await syntax but not what it desugars to.
A closure explanation that stops at "a function inside a function."
Implementations that ignore `this`/arguments or leak state.
Where to go next
If these feel comfortable, the advanced / principal guide is where it gets genuinely hard, frontend system design, performance at scale, memory leaks, race conditions, and the judgment questions that decide senior offers. If any of the closures or async here felt shaky, drop back to the beginner guide to shore up the foundations first.