Skip to content
ExponaBLOG
JAVASCRIPT & TYPESCRIPT · DAY 005 / 100

Promise vs setTimeout: who runs first, explained simply

A classic interview question with a practical lesson.

Expona Admin7 min read

Promise vs setTimeout: who runs first?

I've watched this question trip up developers during interviews more times than I can count. If you ask yourself "Promise vs setTimeout: who runs first?" and feel uncertain about the answer, you're not alone—but after today, that uncertainty disappears. This is one of those fundamentals that separates developers who understand JavaScript's execution model from those who just happen to write working code.

Why this matters in production

Before we jump into the mechanics, let me tell you why this actually matters. A few years back, I was debugging a race condition in a trading platform where we were fetching user balances via Promise and logging analytics via setTimeout. The analytics always arrived before the balance calculation finished, which broke our state management. The team assumed it was a network timing issue. It wasn't—it was misunderstanding the event loop.

When you're building e-commerce checkouts, real-time notifications, or state management layers, you need to know exactly when your code executes. Getting this wrong introduces subtle bugs that are hellish to trace in production.

Synchronous code runs first

Let's start with the obvious: anything that isn't wrapped in a callback or promise executes immediately, in order, before anything else happens.

typescript
console.log(1);
setTimeout(() => console.log(2), 0);
Promise.resolve().then(() => console.log(3));
console.log(4);

When this code runs, lines 1 and 4 execute right away. The engine prints 1, then 4, because both are synchronous. The setTimeout and Promise are deferred—they're callbacks, not immediate statements.

Think of synchronous code as the main thread work. The JavaScript engine processes your script line-by-line in a single thread, and anything that's directly executable happens now. Everything else—timers, promises, I/O—gets queued for later.

In real code, this is straightforward:

typescript
function initializeApp() {
  console.log('App starting');
  
  const user = fetchUserFromCache(); // Synchronous
  console.log('User loaded:', user.id);
  
  // These two don't run yet:
  setTimeout(() => trackPageView(), 0);
  Promise.resolve().then(() => loadRecommendations());
  
  console.log('Init complete');
}

// Output:
// App starting
// User loaded: 123
// Init complete
// (then recommendations load, then tracking fires)

Then all microtasks (promises)

After all synchronous code finishes, the engine doesn't immediately jump to setTimeout. Instead, it processes the microtask queue. This is where promises live.

Microtasks include:

  • Promise callbacks (then, catch, finally)
  • MutationObserver
  • queueMicrotask() calls
  • Process.nextTick() in Node.js

So in our example, after printing 1 and 4, the engine checks the microtask queue. It finds the promise, runs its callback, and prints 3.

This is the critical insight: promises always run before setTimeout, even if the timeout is 0 milliseconds. A timeout of 0 doesn't mean "run instantly"—it means "run after the current synchronous code and all microtasks finish."

Here's a more practical example from a real checkout flow:

typescript
async function processPayment(orderId: string) {
  // Synchronous
  console.log('Processing order:', orderId);
  
  // Microtask
  const paymentResult = await validatePayment(orderId);
  console.log('Payment validated');
  
  // Another microtask
  await updateInventory(orderId);
  console.log('Inventory updated');
  
  // Macrotask (fires last)
  setTimeout(() => {
    console.log('Analytics sent');
  }, 0);
}

// Output:
// Processing order: ORD-123
// Payment validated
// Inventory updated
// Analytics sent

Notice that both the validation and inventory update (promises) happen before the analytics timeout. This is why promise-based workflows feel "tight"—they're scheduled to run as soon as synchronous work completes, with no delay.

Then the next macrotask (timeout)

Once all microtasks drain from the queue, the engine picks the next macrotask. setTimeout callbacks are macrotasks. So is requestAnimationFrame, I/O operations, and rendering.

This is the last phase of our example. After 1, 4, and 3 are done, the engine grabs setTimeout's callback and prints 2.

The event loop works like this:

  1. Execute all synchronous code (call stack)
  2. Execute all microtasks (promise callbacks)
  3. Render (if needed)
  4. Execute one macrotask (setTimeout, I/O)
  5. Go back to step 2

Why does this matter? In a React component, state updates from promises land in the microtask queue and batch together. But if you schedule with setTimeout, you're pushing work into a separate macrotask, which might trigger a new render cycle. This affects performance.

typescript
function DataFetcher() {
  const [data, setData] = useState(null);
  
  useEffect(() => {
    // This batches together with other microtasks
    Promise.resolve()
      .then(() => fetchData())
      .then(data => setData(data));
    
    // This forces a separate update cycle
    setTimeout(() => {
      fetch('/api/data').then(r => r.json()).then(setData);
    }, 0);
  }, []);
  
  return <div>{data}</div>;
}

In the first case, all state updates batch. In the second, setTimeout creates a macrotask boundary, potentially triggering two render cycles instead of one.

Output: 1, 4, 3, 2

Let's trace through the original example one more time, step by step:

typescript
console.log(1);              // Synchronous → prints 1
setTimeout(() => {
  console.log(2);            // Macrotask (queued)
}, 0);
Promise.resolve().then(() => {
  console.log(3);            // Microtask (queued)
});
console.log(4);              // Synchronous → prints 4

// Event loop phase 1: Synchronous code
// Output: 1, 4

// Event loop phase 2: Microtasks
// Output: 3

// Event loop phase 3: Macrotasks
// Output: 2

// Final output: 1, 4, 3, 2

This is deterministic. Every time you run this code, you get the same order. Not because of timing—because of the event loop's design.

In a real scenario, imagine a form submission:

typescript
function handleSubmit(event: React.FormEvent) {
  event.preventDefault();
  console.log('User clicked submit');  // 1
  
  setTimeout(() => {
    console.log('Tracking event sent');  // 4
  }, 0);
  
  validateForm()  // Returns a promise
    .then(isValid => {
      console.log('Form validated:', isValid);  // 3
    });
  
  console.log('Submission flow initiated');  // 2
}

// Output:
// User clicked submit
// Submission flow initiated
// Form validated: true
// Tracking event sent

Common mistakes

Mistake 1: Thinking setTimeout(0) runs "right away"

It doesn't. It runs after all microtasks. If you have 50 promise callbacks queued, your setTimeout fires last. This is especially dangerous in tight loops where you're queueing both promises and timeouts.

Mistake 2: Assuming nested promises behave differently

They don't. Nested promises still go in the microtask queue:

typescript
console.log(1);

Promise.resolve()
  .then(() => {
    console.log(2);
    return Promise.resolve();
  })
  .then(() => {
    console.log(3);
  });

setTimeout(() => console.log(4), 0);

console.log(5);

// Output: 1, 5, 2, 3, 4
// Not: 1, 5, 2, 4, 3

Mistake 3: Mixing async/await with setTimeout and expecting them to coordinate

Async functions return promises, so their await keywords are microtasks. If you're trying to coordinate timing with setTimeout, you're building on unstable ground.

Mistake 4: Overcomplicating with queueMicrotask()

Some developers use queueMicrotask() thinking it's cleaner than Promise.then(). It's not—it's just more explicit. Use promises for async work, not for scheduling:

typescript
// Don't do this
queueMicrotask(() => console.log('hello'));

// Do this instead
Promise.resolve().then(() => console.log('hello'));

Key takeaways

  • Synchronous code (console.log, variable assignments) runs first, always.
  • After synchronous code completes, the engine processes all microtasks—primarily promise callbacks.
  • Only after the microtask queue is empty does the engine pick the next macrotask, like setTimeout.
  • setTimeout runs last, even with a 0ms delay, because it's a macrotask scheduled after microtasks drain.
  • In production, this affects batching (React state updates), performance (render cycles), and correctness (race conditions in state management).
  • The output of the example code is always: 1, 4, 3, 2. Not because of luck, but because of the event loop.
  • When debugging timing issues, ask yourself: is this synchronous, a microtask, or a macrotask?
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles