Het JavaScript event loop: een diepgaande analyse
Inleiding
De JavaScript event loop is het hart van elke JavaScript applicatie. Het vormt de basis voor het asynchrone gedrag dat webapplicaties in staat stelt om soepel en responsief te blijven tijdens het uitvoeren van complexe taken. In deze gids duiken we diep in de werking van de event loop, van de conceptuele basis tot geavanceerde interacties tussen microtasks en macrotasks, en de implicaties voor rendering en gebruikersinteractie.
Deze kennis is essentieel voor elke front-end ontwikkelaar die performante applicaties wil bouwen en subtiele bugs wil voorkomen die kunnen ontstaan door verkeerd begrip van de execution flow in JavaScript.
Basis van de JavaScript event loop
Conceptueel model
De JavaScript event loop is een mechanisme dat ervoor zorgt dat JavaScript, ondanks zijn single-threaded natuur, asynchrone operaties kan uitvoeren. Je kunt het visualiseren als een continue cyclus:
┌───────────────────────────┐
│ Call Stack │
└─────────────┬─────────────┘
│
│ Taak voltooid
▼
┌───────────────────────────┐
│ Microtask Queue │
└─────────────┬─────────────┘
│
│ Alle microtasks verwerkt
▼
┌───────────────────────────┐
│ Rendering │
└─────────────┬─────────────┘
│
│ Rendering voltooid
▼
┌───────────────────────────┐
│ Macrotask Queue │
└─────────────┬─────────────┘
│
│ Eén macrotask verwerkt
▼
Terug naar beginComponenten van de event loop
De event loop bestaat uit verschillende componenten die samenwerken:
1. Call stack
De call stack is een datastructuur die bijhoudt waar in het programma we ons bevinden. Wanneer een functie wordt aangeroepen, wordt deze bovenop de call stack geplaatst; wanneer de functie klaar is, wordt deze van de stack verwijderd.
function firstFunction() {
console.log('I am the first function');
secondFunction();
console.log('Back in first function');
}
function secondFunction() {
console.log('I am the second function');
}
firstFunction();
// Call stack evolutie:
// 1. [firstFunction]
// 2. [firstFunction, secondFunction]
// 3. [firstFunction]
// 4. []2. Callback queue (macrotask queue)
De callback queue bevat taken die op een later moment moeten worden uitgevoerd. Deze taken worden macrotasks genoemd en worden één voor één verwerkt.
console.log('Start');
setTimeout(() => {
console.log('Macrotask executed');
}, 0);
console.log('End');
// Output:
// "Start"
// "End"
// "Macrotask executed"3. Microtask queue
De microtask queue heeft hogere prioriteit dan de callback queue. Alle microtasks worden verwerkt voordat de browser rendert of voordat een nieuwe macrotask wordt verwerkt.
console.log('Start');
setTimeout(() => {
console.log('Macrotask executed');
}, 0);
Promise.resolve().then(() => {
console.log('Microtask executed');
});
console.log('End');
// Output:
// "Start"
// "End"
// "Microtask executed"
// "Macrotask executed"De execution flow van de event loop
De event loop volgt een specifieke volgorde bij het verwerken van taken:
- Voer alle taken in de call stack uit tot deze leeg is
- Verwerk alle taken in de microtask queue
- Voer UI rendering en painting uit
- Haal één taak uit de macrotask queue en voer deze uit
- Ga terug naar stap 1
Deze cyclus zorgt ervoor dat JavaScript asynchroon kan werken binnen zijn single-threaded model.
Microtasks vs macrotasks
Wat zijn microtasks?
Microtasks zijn kleine, snelle taken die prioriteit krijgen over rendering en macrotasks. Ze worden volledig verwerkt voordat de browser een nieuwe render cyclus start of een macrotask uitvoert.
Bronnen van microtasks:
- Promise handlers (
.then(),.catch(),.finally()) awaitexpressies (na de belofte)queueMicrotask()APIMutationObservercallbacksprocess.nextTick()(in Node.js)
/**
* A function that creates a microtask
*/
function createMicrotask() {
console.log('1. Before microtask creation');
// This is a microtask
Promise.resolve().then(() => {
console.log('3. Microtask executed');
});
// This is a second microtask
queueMicrotask(() => {
console.log('4. Explicit microtask executed');
});
console.log('2. After microtask creation');
}
createMicrotask();
// Output:
// "1. Before microtask creation"
// "2. After microtask creation"
// "3. Microtask executed"
// "4. Explicit microtask executed"Wat zijn macrotasks?
Macrotasks zijn reguliere taken die worden uitgevoerd in de hoofdqueue van de event loop. Er wordt slechts één macrotask verwerkt per iteratie van de event loop, waarna de microtask queue en rendering worden afgehandeld.
Bronnen van macrotasks:
setTimeout(),setInterval()- UI events (click, keypress, etc.)
requestAnimationFrame()- I/O operaties
setImmediate()(in Node.js)
/**
* A function that creates a macrotask
*/
function createMacrotask() {
console.log('1. Before macrotask creation');
// This is a macrotask
setTimeout(() => {
console.log('5. Macrotask executed');
}, 0);
// This is a microtask
Promise.resolve().then(() => {
console.log('3. Microtask executed');
// Nested microtask
Promise.resolve().then(() => {
console.log('4. Nested microtask executed');
});
});
console.log('2. After macrotask creation');
}
createMacrotask();
// Output:
// "1. Before macrotask creation"
// "2. After macrotask creation"
// "3. Microtask executed"
// "4. Nested microtask executed"
// "5. Macrotask executed"Vergelijking van microtasks en macrotasks
| Eigenschap | Microtasks | Macrotasks |
|---|---|---|
| Prioriteit | Hoog | Laag |
| Verwerking per loopcyclus | Alle wachtende microtasks | Slechts één macrotask |
| Uitgevoerd vóór rendering | Ja | Nee |
| Typische bronnen | Promise callbacks, queueMicrotask | setTimeout, events, requestAnimationFrame |
| Geschikt voor | Kleine updates, state synchronisatie | UI-events, timers, grote berekeningen |
| Kan rendering blokkeren | Ja, als er teveel zijn | Nee, rendering gebeurt tussen macrotasks |
Het queueMicrotask() patroon
Wat doet queueMicrotask()?
Het queueMicrotask() patroon is een elegante techniek om code naar de microtask queue te verplaatsen:
function example() {
console.log('1. Beginning of function');
queueMicrotask(() => {
// Alles binnen deze callback wordt als microtask uitgevoerd
console.log('3. In microtask');
});
console.log('2. Synchronous code after microtask scheduling');
}
console.log('Start');
example();
console.log('End of script');
// Output:
// "Start"
// "1. Beginning of function"
// "2. Synchronous code after microtask scheduling"
// "End of script"
// "3. In microtask"Hoe werkt queueMicrotask() onder de motorkap?
Wanneer de JavaScript-engine queueMicrotask() tegenkomt:
- De opgegeven callback functie wordt direct aan de microtask queue toegevoegd
- De huidige functie wordt verder uitgevoerd
- Nadat de call stack leeg is, wordt de microtask queue verwerkt
- De callback functie wordt uitgevoerd als onderdeel van de microtask queue verwerking
function technicalQueueMicrotask() {
console.log('1. Function start');
// Add callback to microtask queue
queueMicrotask(() => {
console.log('3. Inside microtask callback');
});
console.log('2. Synchronous code after queueMicrotask call');
}
console.log('Script start');
technicalQueueMicrotask();
console.log('Script end');
// Output:
// "Script start"
// "1. Function start"
// "2. Synchronous code after queueMicrotask call"
// "Script end"
// "3. Inside microtask callback"Praktische toepassingen van queueMicrotask()
1. Event propagatie toestaan
Een veelvoorkomende toepassing is om event propagatie volledig te laten voltooien voordat eigen logica wordt uitgevoerd:
class MyComponent extends LitElement {
private handleButtonClick(event: Event) {
// Allow the click event to fully propagate
queueMicrotask(() => {
// Now we can check if another handler called preventDefault()
if (event.defaultPrevented) {
console.log('Another handler canceled the action');
return;
}
// Execute our logic
this.updateState();
});
}
}2. Voorkomen van UI-blokkering
Door lange berekeningen op te splitsen met queueMicrotask() kan de browser tussendoor renderen:
function processDatasetInBatches(items: any[]) {
const results = [];
let i = 0;
function processNextBatch() {
// Process up to 100 items
const end = Math.min(i + 100, items.length);
for (; i < end; i++) {
results.push(processItem(items[i]));
}
// If there are more items to process
if (i < items.length) {
// Schedule the next batch as a microtask
queueMicrotask(processNextBatch);
// UI can update now before we continue
}
}
// Start processing the first batch
processNextBatch();
return results;
}3. Nauwkeurige controle over executievolgorde
queueMicrotask() kan worden gebruikt om de volgorde van operaties nauwkeurig te controleren:
function complexUpdateSequence() {
// Phase 1: DOM update
this.shadowRoot.querySelector('.content').innerHTML = this.renderContent();
// Wait for rendering after DOM update
queueMicrotask(() => {
// Phase 2: Perform measurements (happens after rendering)
const height = this.shadowRoot.querySelector('.content').offsetHeight;
// Update state with new measurements
this.height = height;
// Wait for next rendering
queueMicrotask(() => {
// Phase 3: Start animation (happens after second rendering)
this.shadowRoot.querySelector('.content').classList.add('animated');
});
});
}De event loop en rendering
Wanneer wordt rendering uitgevoerd?
Rendering in browsers volgt een specifiek patroon in relatie tot de event loop:
- Voer alle taken in de call stack uit
- Verwerk alle wachtende microtasks
- Voer rendering uit (als er wijzigingen zijn)
- Verwerk één macrotask
- Herhaal
Dit betekent dat rendering altijd plaatsvindt na het verwerken van microtasks maar vóór het verwerken van de volgende macrotask.
console.log('1. Script start');
// Macrotask
setTimeout(() => {
console.log('5. setTimeout callback (macrotask)');
// DOM change in macrotask
document.body.style.backgroundColor = 'green';
}, 0);
// Microtask
queueMicrotask(() => {
console.log('3. Microtask callback');
// DOM change in microtask
document.body.style.backgroundColor = 'red';
// Long calculation blocking rendering
const start = performance.now();
while (performance.now() - start < 100) {
// Blocking loop for 100ms
}
console.log('4. Long calculation completed');
});
// Synchronous DOM change
document.body.style.backgroundColor = 'blue';
console.log('2. Script end');
// Visual sequence:
// 1. Page starts with default background
// 2. Script changes to blue (not yet shown)
// 3. Microtask changes to red (not yet shown)
// 4. After 100ms block: Rendering - page becomes red
// 5. Macrotask changes to green
// 6. Rendering - page becomes greenDingen die rendering kunnen triggeren
De browser beslist wanneer rendering plaatsvindt, maar bepaalde acties kunnen een render "request" veroorzaken:
- DOM-wijzigingen
- Stijlwijzigingen
- Mediaquery veranderingen
- Resize events
- Scroll events
- Animaties
Echter, browsers optimaliseren rendering en kunnen meerdere wijzigingen batchen.
requestAnimationFrame vs microtasks
Een belangrijke nuance is hoe requestAnimationFrame zich verhoudt tot microtasks:
console.log('1. Script start');
// Macrotask via setTimeout
setTimeout(() => {
console.log('6. setTimeout callback');
}, 0);
// Microtask via queueMicrotask
queueMicrotask(() => {
console.log('3. First microtask');
});
// Request an animation frame
requestAnimationFrame(() => {
console.log('5. Animation frame');
});
// Another microtask
queueMicrotask(() => {
console.log('4. Second microtask');
});
console.log('2. Script end');
// Output order:
// "1. Script start"
// "2. Script end"
// "3. First microtask"
// "4. Second microtask"
// "5. Animation frame"
// "6. setTimeout callback"requestAnimationFrame callbacks worden uitgevoerd ná alle microtasks maar vóór rendering en vóór de volgende macrotask. Dit maakt ze ideaal voor animaties en visualisaties.
Geavanceerde patronen
1. Task scheduling met microtasks en macrotasks
Door bewuste keuzes te maken tussen microtasks en macrotasks kun je de gebruikerservaring optimaliseren:
class DataProcessingApp {
private data: any[] = [];
/**
* Processes a large dataset with different priority levels
*/
processDataset(items: any[]) {
// High priority: Show loading indicator (microtask)
this.showLoadingIndicator();
// Medium priority: Show first results quickly (microtasks)
queueMicrotask(() => {
const firstResults = this.processFirstItems(items.slice(0, 10));
this.displayResults(firstResults);
// Low priority: Process remaining items in the background (macrotasks)
this.processRemainingItemsInBackground(items.slice(10));
});
}
private showLoadingIndicator() {
this.loadingElement.style.display = 'block';
// Use microtask to ensure UI updates before we continue
queueMicrotask(() => {
// UI has updated, ready for next step
});
}
private processFirstItems(items: any[]) {
const results = [];
let i = 0;
const processNextItem = () => {
if (i < items.length) {
results.push(this.processItem(items[i++]));
// Schedule next item as microtask for responsive UI
queueMicrotask(processNextItem);
}
};
processNextItem();
return results;
}
private processRemainingItemsInBackground(items: any[]) {
let index = 0;
const processBatch = () => {
const start = performance.now();
// Process items until time budget is exhausted or all items processed
while (index < items.length && performance.now() - start < 16) {
this.processItem(items[index++]);
}
// Update progress
this.updateProgressIndicator(index / items.length);
// If more items to process, schedule next batch as a macrotask
if (index < items.length) {
setTimeout(processBatch, 0);
} else {
this.finishProcessing();
}
};
// Start processing first batch
setTimeout(processBatch, 0);
}
private processItem(item: any) {
// Item processing logic
return { processed: item };
}
private updateProgressIndicator(progress: number) {
this.progressElement.style.width = `${progress * 100}%`;
}
private displayResults(results: any[]) {
// Display logic
}
private finishProcessing() {
this.loadingElement.style.display = 'none';
}
}2. Event handlers en microtask scheduling
Door de event loop te begrijpen, kun je beter voorspelbare en performante event handlers schrijven:
class ResponsiveComponent extends HTMLElement {
connectedCallback() {
this.button = this.querySelector('button');
this.button.addEventListener('click', this.handleClick.bind(this));
}
/**
* Click handler that demonstrates microtask and macrotask scheduling
*/
handleClick(event: MouseEvent) {
// Allow event to propagate before our logic
queueMicrotask(() => {
// Check if default action was prevented by other handlers
if (event.defaultPrevented) return;
// Immediate feedback (microtask)
this.showVisualFeedback();
// Data processing (microtask, but yielding to render)
this.processDataWithYield().then((result) => {
// Update UI with result
this.updateUI(result);
// Analytics (low priority, use macrotask)
setTimeout(() => {
this.sendAnalytics();
}, 0);
});
});
}
private showVisualFeedback() {
this.classList.add('processing');
}
private processDataWithYield() {
return new Promise((resolve) => {
let result = {};
const steps = 5;
let i = 0;
const processNextStep = () => {
if (i < steps) {
// Do some work for this step
const stepResult = this.processStep(i++);
Object.assign(result, stepResult);
// Yield to rendering between steps
queueMicrotask(processNextStep);
} else {
resolve(result);
}
};
processNextStep();
});
}
private processStep(step: number) {
// Processing logic
return { [`step${step}`]: 'completed' };
}
private updateUI(result: any) {
this.classList.remove('processing');
this.classList.add('completed');
// Update UI with result data
}
private sendAnalytics() {
// Non-critical analytics code
console.log('Analytics sent');
}
}3. Complexe rendering en animatie orchestratie
Voor zeer nauwkeurige controle over animaties en rendering, kun je verschillende timingmechanismen combineren:
class AnimationCoordinator {
/**
* Orchestrates a complex animation sequence with precise timing
*/
animateElementSequence(elements: HTMLElement[]) {
// Initial setup
elements.forEach((el) => el.classList.add('prepare-animation'));
// Wait for render using microtask
queueMicrotask(() => {
// Start measuring (after render)
const measurements = elements.map((el) => ({
height: el.offsetHeight,
width: el.offsetWidth,
}));
// Apply measurements
elements.forEach((el, i) => {
el.style.setProperty('--original-height', `${measurements[i].height}px`);
el.style.setProperty('--original-width', `${measurements[i].width}px`);
});
// Wait for another render cycle
queueMicrotask(() => {
// Start animation sequence with requestAnimationFrame
// (runs before render but after microtasks)
requestAnimationFrame(() => {
elements.forEach((el, index) => {
// Stagger animations
el.style.animationDelay = `${index * 50}ms`;
el.classList.add('animate');
});
// Wait for all animations to complete
const lastElement = elements[elements.length - 1];
lastElement.addEventListener(
'animationend',
() => {
// Cleanup after animation (macrotask to ensure low priority)
setTimeout(() => {
elements.forEach((el) => {
el.classList.remove('prepare-animation', 'animate');
el.style.removeProperty('--original-height');
el.style.removeProperty('--original-width');
});
}, 0);
},
{ once: true }
);
});
});
});
}
}Veelvoorkomende valkuilen
1. Blokkeren van de main thread
Probleem: Lange synchrone operaties blokkeren de event loop.
// Problematic code
function processLargeArray(items: number[]) {
const results = [];
// This can block the main thread for a long time
for (let i = 0; i < items.length; i++) {
results.push(heavyCalculation(items[i]));
}
return results;
}
// Better approach
function processLargeArrayAsync(items: number[]) {
return new Promise((resolve) => {
const results = [];
const BATCH_SIZE = 100;
let i = 0;
function processNextBatch() {
// Process one batch
const end = Math.min(i + BATCH_SIZE, items.length);
for (; i < end; i++) {
results.push(heavyCalculation(items[i]));
}
// If more items to process
if (i < items.length) {
// Yield to the event loop between batches
queueMicrotask(processNextBatch);
} else {
resolve(results);
}
}
processNextBatch();
});
}2. Verkeerd begrijpen van event volgorde
Probleem: Verkeerde aannames over de volgorde van events.
// Problematic code
function setupHandlers() {
button.addEventListener('click', () => {
// Change DOM
container.innerHTML = 'Loading...';
// This won't work as expected because the DOM update
// hasn't been rendered yet
const height = container.offsetHeight;
// Using height will give incorrect results
adjustLayout(height);
});
}
// Better approach
function setupHandlers() {
button.addEventListener('click', () => {
// Change DOM
container.innerHTML = 'Loading...';
// Wait for render using microtask
queueMicrotask(() => {
// Now we can measure the actual rendered height
const height = container.offsetHeight;
// Correct height value
adjustLayout(height);
});
});
}3. Oneindige microtask loops
Probleem: Microtasks die nieuwe microtasks creëren kunnen de browser blokkeren.
// Dangerous code - will freeze the browser
function createInfiniteMicrotaskLoop() {
queueMicrotask(() => {
// This creates another microtask, creating an infinite loop
// that prevents rendering and macrotasks
createInfiniteMicrotaskLoop();
});
}
// Safe pattern that allows UI to update
function createSafeLongRunningProcess() {
let counter = 0;
function processBatch() {
// Do some work
for (let i = 0; i < 1000; i++) {
counter++;
}
// Continue if needed, but use setTimeout to yield to rendering
if (counter < 1000000) {
setTimeout(processBatch, 0); // Using macrotask allows rendering
}
}
processBatch();
}4. Inconsistent timing in tests
Probleem: Tests die falen door race conditions in de event loop.
// Problematic test
test('component updates after data change', () => {
const component = new MyComponent();
component.data = newData;
// This might fail because the component update happens in a microtask
expect(component.textContent).toBe('New content');
});
// Better approach
test('component updates after data change', (done) => {
const component = new MyComponent();
component.data = newData;
// Wait for all microtasks to complete
queueMicrotask(() => {
// Now the component has had a chance to update
expect(component.textContent).toBe('New content');
done();
});
});Conclusie
De JavaScript event loop is een fundamenteel mechanisme dat het gedrag van elke webapplicatie bepaalt. Het begrip van de interactie tussen microtasks, macrotasks en rendering is essentieel voor het bouwen van performante en responsieve applicaties.
Door bewust gebruik te maken van queueMicrotask() en andere planningsmechanismen, kunnen ontwikkelaars nauwkeurige controle krijgen over wanneer code wordt uitgevoerd, zonder dat dit ten koste gaat van de gebruikerservaring.
De belangrijkste principes om te onthouden:
Synchrone code → Microtasks → Rendering → Macrotasks: Dit is de volgorde van uitvoering in de event loop.
Gebruik microtasks voor hoge prioriteit werk dat moet gebeuren vóór de volgende render cyclus.
Gebruik macrotasks voor lage prioriteit werk dat kan wachten tot na een render cyclus.
Splits lange berekeningen op om de main thread niet te blokkeren en de UI responsief te houden.
queueMicrotask()is een krachtig patroon om code in te plannen als een microtask.
Door deze principes toe te passen, kun je de event loop benutten om zowel performante als onderhoudbare code te schrijven.