In JavaScript, once a function starts running, can a pending timer callback or promise reaction interrupt it partway through? Explain what run-to-completion guarantees and where that guarantee stops.
answer
- nothing is preempted between statements
- a job runs until the stack empties
- no locks needed for shared objects
- the unit is the job, not the function
- every await is a seam
basics
~20 sNo. A JavaScript job runs until its call stack is empty, and only then does the runtime pick up the next callback, so nothing is preempted between statements. The guarantee covers one uninterrupted synchronous run — an await ends it and starts a new job.
solid answer
~50 sNo — nothing can be slipped in. JavaScript runs each job to completion: the runtime takes one callback, runs it until the call stack is empty, and only then considers the next. There is no preemption point between my statements, so a timer callback or a promise reaction cannot observe a half-updated object or a loop midway through. That is why ordinary JavaScript needs no locks and no atomics for shared state. The guarantee is per *job*, though, not per function: when an `async` function hits an `await`, it returns and its remaining code becomes a later job, so other code runs in the gap and any state I cached before the await may be stale afterwards. Synchronous callbacks are not protected either — code I call can re-enter my module on the same stack, inside the same job, and see my invariants mid-flight.
code
javascript · 9 lineslet n = 0;
setTimeout(() => console.log('timer sees', n), 0);
for (let i = 0; i < 1e6; i++) n++;
console.log('sync done', n);
// sync done 1000000
// timer sees 1000000 <- never an intermediate valuego deeper
Be able to state plainly that a callback never runs in the middle of your function — it waits until the current code has finished — and that this is why simple shared variables are safe.
Explain the mechanism: the runtime drains the stack before taking the next job, so there is no preemption point between statements, and show that the guarantee is per job by pointing at what an await splits.
Demonstrate the judgment: identify read-then-await-then-write sequences as the real JavaScript race, and know that synchronous re-entrancy through callbacks bypasses the guarantee entirely.
Own it as an API-design constraint: where you place awaits defines your consistency boundaries, and library surfaces that invoke user callbacks mid-update publish their internal invariants whether you intended to or not.
## What run-to-completion actually says A JavaScript runtime processes work in units usually called *jobs* or *tasks*: the initial evaluation of a script, a timer callback, an event handler, a promise reaction. The rule is that once one of these starts, it runs until the call stack is empty again. The runtime never suspends it partway to run something else and resume it later. Only when the stack drains does the loop pick up the next unit of work. So the answer to "can a pending callback interrupt my function?" is a flat no. A callback whose time has come simply waits. Its turn arrives when — and only when — the currently running job finishes and the stack is empty. ## Why that is a big deal In a preemptively scheduled language, a thread can be paused between any two machine instructions, so another thread may observe an object mid-update: half the fields written, an array resized but not filled, an invariant temporarily false. That is why such languages need locks, atomics and memory models. JavaScript hands you the strong property for free. Consider: ```js let n = 0; setTimeout(() => console.log('timer sees', n), 0); for (let i = 0; i < 1e6; i++) n++; console.log('sync done', n); ``` The output is `sync done 1000000` and then `timer sees 1000000`. There is no interleaving in which the timer prints `473812`. The timer callback is a separate job; it cannot begin until the loop's job has fully finished. The same holds for any multi-step update: if you write three fields of an object in a row, no other JavaScript in that agent can ever observe the object with only two of them written. This is the reason you never see mutexes or compare-and-swap in ordinary JavaScript code. They would have nothing to protect against. ## Where the guarantee stops: the job boundary The unit of protection is the job, not the function and not the module. Two things end a job in the middle of what looks like one piece of logic. The first is `await` (and, equivalently, a `.then` callback). When an async function reaches an `await`, it *returns* — its frame pops, the stack drains, the job ends. The code after the await is scheduled as a separate job and runs later, on a fresh stack. Everything queued in between gets to run first. So this is not atomic: ```js async function transfer(account) { const balance = account.balance; // job 1 reads await audit(account); // job 1 ends here account.balance = balance - 10; // job 2 writes a value read long ago } ``` If anything else touched `account.balance` while `audit` was pending, this write clobbers it. Nothing preempted `transfer`; the function was simply split into two jobs, and other jobs ran in the gap. The practical discipline is to re-read shared state *after* awaiting rather than caching it across the boundary, and to treat every `await` as a visible seam in your logic. The second is synchronous re-entrancy. Run-to-completion says nothing can be forced in; it does not say you cannot call out. If your code invokes a callback, a getter, a `toString`, or a listener that calls back into your own module, all of that runs on the same stack within the same job — and it can observe your object exactly as it is right now, halfway through your update. Libraries hit this constantly: finish mutating and restore your invariants first, notify afterwards, and be deliberate about what a handler is allowed to do while you are still inside your own critical section. ## The other edge of the same knife The guarantee is symmetrical. Because nothing can preempt a running job, nothing can rescue the runtime from a job that takes a long time either. A loop that runs for four seconds simply holds the stack for four seconds, and every queued callback waits. Run-to-completion is why JavaScript is easy to reason about *and* why a single slow function stalls everything. ## Common misreadings "`setTimeout(fn, 0)` runs `fn` immediately" — it cannot; zero only means "as soon as the queue reaches you", which is after the current job. "`await` pauses the thread" — it does the opposite: it releases the thread by ending the job. "Run-to-completion means an async function runs uninterrupted" — only each of its segments does. "JavaScript needs locks like other languages" — for ordinary shared objects within one agent, it does not.
- Does an async function keep occupying a call-stack frame while it is awaiting?No. At the `await` the function returns to its caller and its frame pops, so the stack can drain and other jobs can run. The engine keeps the function's state elsewhere and resumes the remainder later as a fresh job, starting from an empty stack. That is precisely why awaiting frees the runtime instead of blocking it.
- If nothing can preempt my code, why do I still see stale values after an await?Because the await ended one job and the rest of your function is a different one. Between them, other jobs ran and may have mutated the same objects. Nothing interrupted you; your logic was simply split. Re-read shared state after awaiting instead of caching it across the boundary, and treat any value read before an await as potentially outdated.
- Does run-to-completion protect a library from re-entrant calls?No. A callback, a getter, or a listener that your code invokes runs on the same stack inside the same job, so it can observe your data structure mid-update and even call back into you recursively. Finish mutating and restore your invariants before notifying anyone, and guard against re-entry explicitly if a handler can trigger you again.
saying these in an interview costs you the question
- JavaScript needs mutexes to protect a shared counter
- setTimeout with zero delay interrupts the running function
- await pauses the thread until the promise settles
- Promise callbacks can fire in the middle of a loop
- An entire async function runs as one uninterrupted unit