Skip to content

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 begin

Componenten 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.

javascript
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.

javascript
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.

javascript
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:

  1. Voer alle taken in de call stack uit tot deze leeg is
  2. Verwerk alle taken in de microtask queue
  3. Voer UI rendering en painting uit
  4. Haal één taak uit de macrotask queue en voer deze uit
  5. 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())
  • await expressies (na de belofte)
  • queueMicrotask() API
  • MutationObserver callbacks
  • process.nextTick() (in Node.js)
typescript
/**
 * 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)
typescript
/**
 * 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

EigenschapMicrotasksMacrotasks
PrioriteitHoogLaag
Verwerking per loopcyclusAlle wachtende microtasksSlechts één macrotask
Uitgevoerd vóór renderingJaNee
Typische bronnenPromise callbacks, queueMicrotasksetTimeout, events, requestAnimationFrame
Geschikt voorKleine updates, state synchronisatieUI-events, timers, grote berekeningen
Kan rendering blokkerenJa, als er teveel zijnNee, 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:

typescript
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:

  1. De opgegeven callback functie wordt direct aan de microtask queue toegevoegd
  2. De huidige functie wordt verder uitgevoerd
  3. Nadat de call stack leeg is, wordt de microtask queue verwerkt
  4. De callback functie wordt uitgevoerd als onderdeel van de microtask queue verwerking
typescript
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:

typescript
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:

typescript
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:

typescript
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:

  1. Voer alle taken in de call stack uit
  2. Verwerk alle wachtende microtasks
  3. Voer rendering uit (als er wijzigingen zijn)
  4. Verwerk één macrotask
  5. Herhaal

Dit betekent dat rendering altijd plaatsvindt na het verwerken van microtasks maar vóór het verwerken van de volgende macrotask.

typescript
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 green

Dingen die rendering kunnen triggeren

De browser beslist wanneer rendering plaatsvindt, maar bepaalde acties kunnen een render "request" veroorzaken:

  1. DOM-wijzigingen
  2. Stijlwijzigingen
  3. Mediaquery veranderingen
  4. Resize events
  5. Scroll events
  6. Animaties

Echter, browsers optimaliseren rendering en kunnen meerdere wijzigingen batchen.

requestAnimationFrame vs microtasks

Een belangrijke nuance is hoe requestAnimationFrame zich verhoudt tot microtasks:

typescript
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:

typescript
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:

typescript
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:

typescript
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.

typescript
// 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.

typescript
// 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.

typescript
// 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.

typescript
// 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:

  1. Synchrone code → Microtasks → Rendering → Macrotasks: Dit is de volgorde van uitvoering in de event loop.

  2. Gebruik microtasks voor hoge prioriteit werk dat moet gebeuren vóór de volgende render cyclus.

  3. Gebruik macrotasks voor lage prioriteit werk dat kan wachten tot na een render cyclus.

  4. Splits lange berekeningen op om de main thread niet te blokkeren en de UI responsief te houden.

  5. 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.