현재 실행 중인 코드가 끝난 직후, 다음 태스크로 넘어가기 전에 몰아서 처리되는 작은 작업. 이벤트 루프는 태스크 하나를 끝낼 때마다 마이크로태스크 큐가 빌 때까지 전부 실행한다.
- 들어가는 것: Promise의
then·catch·finally콜백,await다음에 이어지는 코드(continuation),queueMicrotask(),MutationObserver콜백 - 같은 태스크 안에서
setTimeout을 먼저 등록했어도 마이크로태스크가 항상 먼저 실행된다 - 처리 중에 새로 추가된 마이크로태스크도 같은 차례에 처리한다. 그래서 마이크로태스크가 자기 자신을 계속 등록하면 루프를 빠져나가지 못하고 화면이 멈춘다. 같은 재귀라도
setTimeout으로 등록하면 사이사이 렌더링이 끼어들어서 화면은 살아 있다
function loopTimeout() { setTimeout(loopTimeout) } // 느리지만 화면은 그려짐
function loopMicro() { queueMicrotask(loopMicro) } // 탭이 멈춤굶김을 피하려면
이렇게 마이크로태스크가 큐를 독차지해 렌더링·입력·타이머가 밀리는 현상을 마이크로태스크 굶김(Microtask Starvation)이라고 한다. 흔한 함정은 await다. 이미 끝난 Promise를 await해도 이어지는 코드는 마이크로태스크라서, 루프 안에서 await Promise.resolve()를 반복하면 비동기처럼 보여도 브라우저에 한 번도 양보하지 않는다.
큰 작업은 청크로 나누고, 청크 사이에 태스크로 양보한다. 메인 스레드를 50ms 넘게 붙잡는 작업(롱 태스크)을 줄이는 기본 방법이기도 하다.
const yieldToMain = () =>
globalThis.scheduler?.yield?.() ?? new Promise(r => setTimeout(r, 0))
for (let i = 0; i < items.length; i += 100) {
items.slice(i, i + 100).forEach(process)
await yieldToMain() // 여기서 렌더링·입력 처리가 끼어든다
}setTimeout(fn, 0)은 콜백을 별도 태스크로 미룬다. 다만 중첩이 5단계를 넘으면 명세상 최소 4ms 지연이 붙는다scheduler.yield()는 양보한 뒤 이어지는 코드를 같은 우선순위의 다른 태스크보다 앞에 다시 세운다. 2026 기준 Baseline이 아니라 위처럼 대체 코드를 둔다- 화면 갱신과 맞물린 작업은
requestAnimationFrame(다음 리페인트 직전), 급하지 않은 작업은requestIdleCallback(유휴 시간, 꼭 해야 하면timeout지정)에 맡긴다 - 계산 자체가 무거우면 메인 스레드 밖 Web Worker로 옮긴다
then 체인의 순서
체인의 다음 then은 앞 콜백이 끝나야 큐에 들어간다. 그래서 두 체인을 나란히 만들면 단계별로 번갈아 실행된다.
Promise.resolve().then(() => log('1-1')).then(() => log('1-2'))
Promise.resolve().then(() => log('2-1')).then(() => log('2-2'))
// 1-1, 2-1, 1-2, 2-2React가 일부 상태 업데이트를 모아 처리하거나(자동 배칭) 상태 관리 라이브러리가 알림을 미루는 데에도 이 "현재 코드 직후" 타이밍이 쓰인다.
참고: MDN — Using microtasks in JavaScript with queueMicrotask()
명세: HTML Standard — timer initialisation steps
출처: web.dev — Optimize long tasks · MDN — Scheduler.yield() · MDN — requestAnimationFrame() · MDN — requestIdleCallback()