Build a JavaScript Event Loop Visualizer

Visualize how JavaScript processes async code with a TypeScript simulator.

Introduction

30 Second Summary

You have probably written setTimeout and Promise.then dozens of times, but if someone asked you to predict the exact console output order of a mixed async snippet, you might hesitate. That hesitation comes from not having a mechanical model of how JavaScript actually schedules work.

In this project, you will build an interactive browser tool that visualizes how JavaScript processes asynchronous code. You will watch colored task cards flow between the call stack, microtask queue, and macrotask queue as you step through pre-built scenarios.

What You'll Build

A TypeScript-powered event loop visualizer that animates code execution through the call stack, microtask queue, and macrotask queue with plain-English explanations at every step.

By the end of this project, you'll have:

  • A TypeScript type system modeling the event loop's components (tasks, snapshots, and scenarios).
  • A simulation engine that generates step-by-step snapshots following the event loop's priority rules.
  • A visual UI with animated transitions, auto-play controls, and multiple scenarios demonstrating key async behaviors.
  • Secret Mission: Add an async/await scenario that reveals how await desugars to microtasks, including a tricky multiple-awaits case.

Are there any prerequisites?

You should be comfortable writing TypeScript and have Node.js and VS Code installed. No prior knowledge of event loop internals is needed. That is exactly what you will learn by building this.

Scaffold the Project

To build the event loop visualizer, you need a TypeScript project with a local dev server that reloads your changes instantly.

You will use Vite to scaffold a vanilla TypeScript project. Vite gives you a lightning-fast dev server and handles TypeScript compilation out of the box.

In this step, get ready to:
  • Scaffold a vanilla TypeScript project with Vite.
  • Remove the default template files for a clean starting point.
  • Create the file structure for the visualizer.
Run the Vite scaffold command

Vite's project scaffolder creates a ready-to-run TypeScript project in one command. It sets up the build tooling, TypeScript config, and a dev server for you.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold the project by running this command:
npm create vite@latest event-loop-visualizer -- --template vanilla-ts

What does this command do?

  • npm create vite@latest runs Vite's project scaffolder at the latest version.
  • event-loop-visualizer is the name of the folder that gets created.
  • -- --template vanilla-ts tells Vite to use the vanilla TypeScript template with no framework.
  • Move into the new project folder and install dependencies by running these commands:
cd event-loop-visualizer
npm install
  • Start the dev server by running:
npm run dev
  • Open your browser and navigate to http://localhost:5173. You should see a default Vite starter page with a counter button.

Don't see the page?

  • Make sure you ran npm install before npm run dev. Without installing dependencies first, the dev server cannot start.
  • Check your terminal output for errors. If the port is already in use, stop whatever is running on port 5173 and try again.

Help me troubleshoot my Vite dev server not starting.

Clean out the default template files

The Vite scaffold comes with demo files you do not need. Removing them gives you a blank canvas to build the visualizer on.

  • Stop the dev server by pressing Ctrl+C in your terminal.
  • Delete the default template files by running this command:
rm src/counter.ts src/typescript.svg src/style.css public/vite.svg

What are these files?

  • counter.ts is a demo counter component you will not use.
  • typescript.svg and vite.svg are logo assets from the default page.
  • style.css contains default styles that would conflict with your custom design.
  • Now replace the contents of index.html with this simplified version. Open index.html in VS Code and replace everything with:
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Event Loop Visualizer</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

What changed?

You removed the favicon link and default body content that Vite scaffolds. The page now has an empty <div id="app"> container and a script tag pointing to your main.ts entry point.

  • Clear all the content from src/main.ts. Open the file in VS Code, select all (Cmd+A on macOS or Ctrl+A on Windows), and delete it. Save the empty file.
  • Confirm the cleanup worked by running:
ls src/
  • You should see only main.ts listed in the output. The other files are gone.

Still seeing extra files?

Double check that you ran the rm command from inside the event-loop-visualizer folder. If you ran it from the wrong directory, the files will still be there.

Help me clean up my Vite project template files.

Create the file structure for the visualizer

Your visualizer needs two new files: one for TypeScript type definitions and one for the simulation engine.

  • Create the empty files by running these commands:
touch src/types.ts src/engine.ts

What are these files for?

  • types.ts will hold the TypeScript interfaces that model the event loop (tasks, snapshots, scenarios).
  • engine.ts will hold the simulation logic that generates step-by-step snapshots.
  • main.ts (already exists but is empty) will wire everything together and handle the UI.
  • Confirm all files are in place by running:
ls src/
  • You should see engine.ts, main.ts, and types.ts listed.
  • Restart the dev server by running:
npm run dev
  • Open http://localhost:5173 in your browser. You should see a completely blank white page with no errors in the browser console.
  • Open the browser console (Cmd+Option+J on macOS or Ctrl+Shift+J on Windows in Chrome) and confirm there are no red error messages.

Seeing errors in the console?

If you see an error about a missing style.css import, make sure you cleared all the content from src/main.ts. The default main.ts imports style.css, and since you deleted that file, the import would fail.

Help me fix console errors in my blank Vite project.

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files before moving on.

ⓧ I'd like to double check the full code

Here is what your index.html should look like:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Event Loop Visualizer</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

Your src/main.ts, src/types.ts, and src/engine.ts should all be completely empty files with no content.

Your blank canvas is ready. Next up, you will define the TypeScript data model that describes the event loop's components.

Define the Event Loop Data Model

Your project is scaffolded and the dev server is running. Now you need to teach your app what the event loop actually looks like as data.

Before you can visualize anything, you need a way to represent the state of the JavaScript runtime at any given moment. What is on the call stack? What is waiting in the microtask queue? What about the macrotask queue? In this step, you will define TypeScript interfaces to model these components, then build a simulation engine that generates step-by-step snapshots following the event loop's priority rules.

In this step, get ready to:
  • Define TypeScript interfaces for the event loop's components.
  • Implement helper functions that create immutable state snapshots.
  • Build a scenario function that encodes the event loop rules for setTimeout vs Promise.
Define the type interfaces

Every piece of data in your visualizer needs a shape. A Task is the smallest unit of work (a function call, a callback). A Snapshot captures the entire system at one moment in time. A Scenario bundles a sequence of snapshots with the source code they illustrate.

  • Define the Task, Snapshot, and Scenario interfaces in src/types.ts by adding this code:
export interface Task {
  id: string;
  label: string;
}

export interface Snapshot {
  callStack: Task[];
  microtaskQueue: Task[];
  macrotaskQueue: Task[];
  log: string[];
  description: string;
  highlight: "callstack" | "microtask" | "macrotask" | "log" | "none";
}

export interface Scenario {
  title: string;
  code: string;
  snapshots: Snapshot[];
}

What does this code do?

Task has an id for unique identification and a label that describes the work (like console.log("start")).

Snapshot holds four arrays (call stack, microtask queue, macrotask queue, and console log), a human-readable description of what just happened, and a highlight field that tells the UI which column to emphasize.

Scenario ties together a title, the source code being demonstrated, and the ordered list of snapshots the visualization will step through.

  • Save src/types.ts.
  • You won't see a change in the browser yet. The next chunk uses these types.

Seeing red squiggles in VS Code?

Make sure you saved the file. If TypeScript still shows errors, check that each interface is exported with the export keyword so other files can import them.

help me fix TypeScript errors in my types.ts file

✔️ Awesome, I've got everything!

Great. Make sure you saved src/types.ts before moving on.

ⓧ I'd like to double check the full code

export interface Task {
  id: string;
  label: string;
}

export interface Snapshot {
  callStack: Task[];
  microtaskQueue: Task[];
  macrotaskQueue: Task[];
  log: string[];
  description: string;
  highlight: "callstack" | "microtask" | "macrotask" | "log" | "none";
}

export interface Scenario {
  title: string;
  code: string;
  snapshots: Snapshot[];
}
Implement the snapshot helper functions

Your simulation engine needs two small utilities. One creates a Task with a unique ID. The other takes the current mutable state and returns a frozen copy so each snapshot is independent. Without the copy, every snapshot would point to the same arrays and show only the final state.

  • Add the following code to src/engine.ts:
import { Task, Snapshot, Scenario } from "./types";

let nextId = 0;

function task(label: string): Task {
  return { id: `task-${nextId++}`, label };
}

function snap(
  callStack: Task[],
  microtaskQueue: Task[],
  macrotaskQueue: Task[],
  log: string[],
  description: string,
  highlight: Snapshot["highlight"]
): Snapshot {
  return {
    callStack: [...callStack],
    microtaskQueue: [...microtaskQueue],
    macrotaskQueue: [...macrotaskQueue],
    log: [...log],
    description,
    highlight,
  };
}

What does this code do?

task() is a factory function. Each time you call it, it returns a new Task object with an auto-incrementing ID and the label you pass in.

snap() is the key function. It uses the spread operator ([...array]) to create shallow copies of every array. This means each snapshot is an independent "photograph" of the system at that moment. When you later mutate the arrays to simulate the next step, previous snapshots remain unchanged.

  • Save src/engine.ts.
  • You won't see a visible change yet. The next chunk builds the scenario that uses these helpers.

Import errors on the types?

Make sure the import path is "./types" (with ./ prefix). TypeScript needs a relative path to find src/types.ts from src/engine.ts.

help me fix import path errors in engine.ts

Build the first scenario

Now you will encode the event loop's rules by hand. The classic "setTimeout vs Promise.then" example demonstrates the core priority: synchronous code runs first, then ALL microtasks drain, then ONE macrotask is processed.

  • Add the buildTimeoutVsPromiseScenario() function and the getScenarios() export below the snap() function in src/engine.ts:
function buildTimeoutVsPromiseScenario(): Scenario {
  nextId = 0;
  const stack: Task[] = [];
  const micro: Task[] = [];
  const macro: Task[] = [];
  const log: string[] = [];
  const snapshots: Snapshot[] = [];

  const script = task("script");
  stack.push(script);
  snapshots.push(snap(stack, micro, macro, log, "The script begins executing. It is pushed onto the call stack.", "callstack"));

  const logStart = task('console.log("start")');
  stack.push(logStart);
  snapshots.push(snap(stack, micro, macro, log, 'console.log("start") is called and pushed onto the call stack.', "callstack"));

  log.push("start");
  stack.pop();
  snapshots.push(snap(stack, micro, macro, log, '"start" is logged to the console. console.log pops off the stack.', "log"));

  const stCall = task("setTimeout(cb, 0)");
  stack.push(stCall);
  snapshots.push(snap(stack, micro, macro, log, "setTimeout is called and pushed onto the call stack.", "callstack"));

  stack.pop();
  const stCb = task("timeout callback");
  macro.push(stCb);
  snapshots.push(snap(stack, micro, macro, log, "setTimeout registers its callback in the macrotask queue (even with 0ms delay) and pops off the stack.", "macrotask"));

  const prCall = task("Promise.resolve().then(cb)");
  stack.push(prCall);
  snapshots.push(snap(stack, micro, macro, log, "Promise.resolve().then() is called and pushed onto the call stack.", "callstack"));

  stack.pop();
  const prCb = task(".then callback");
  micro.push(prCb);
  snapshots.push(snap(stack, micro, macro, log, "The Promise resolves immediately. Its .then callback is enqueued in the microtask queue.", "microtask"));

  const logEnd = task('console.log("end")');
  stack.push(logEnd);
  snapshots.push(snap(stack, micro, macro, log, 'console.log("end") is called and pushed onto the call stack.', "callstack"));

  log.push("end");
  stack.pop();
  snapshots.push(snap(stack, micro, macro, log, '"end" is logged to the console. console.log pops off the stack.', "log"));

  stack.pop();
  snapshots.push(snap(stack, micro, macro, log, "The script finishes. It pops off the call stack. The stack is now empty.", "callstack"));

  snapshots.push(snap(stack, micro, macro, log, "EVENT LOOP: Call stack is empty. Check microtask queue first. It has a task! Microtasks always have priority over macrotasks.", "microtask"));

  micro.shift();
  const prExec = task('() => log("microtask")');
  stack.push(prExec);
  snapshots.push(snap(stack, micro, macro, log, "The .then callback is dequeued from microtasks and pushed onto the call stack.", "callstack"));

  log.push("microtask");
  stack.pop();
  snapshots.push(snap(stack, micro, macro, log, '"microtask" is logged. Callback pops off. Microtask queue is now empty.', "log"));

  snapshots.push(snap(stack, micro, macro, log, "EVENT LOOP: Microtask queue empty. Now take ONE macrotask from the macrotask queue.", "macrotask"));

  macro.shift();
  const stExec = task('() => log("timeout")');
  stack.push(stExec);
  snapshots.push(snap(stack, micro, macro, log, "The setTimeout callback is dequeued from macrotasks and pushed onto the call stack.", "callstack"));

  log.push("timeout");
  stack.pop();
  snapshots.push(snap(stack, micro, macro, log, '"timeout" is logged. All queues empty. Event loop is idle. Final output: start, end, microtask, timeout.', "log"));

  return {
    title: "setTimeout vs Promise.then",
    code: `console.log("start");\nsetTimeout(() => console.log("timeout"), 0);\nPromise.resolve().then(() => console.log("microtask"));\nconsole.log("end");\n\n// Output: start, end, microtask, timeout`,
    snapshots,
  };
}

export function getScenarios(): Scenario[] {
  return [buildTimeoutVsPromiseScenario()];
}

What does this code do?

This function manually traces execution through the classic setTimeout vs Promise example. It simulates the JavaScript runtime step by step.

  • Steps 1-10 execute synchronous code: the script runs, logs "start", registers setTimeout's callback in the macrotask queue, registers Promise.then's callback in the microtask queue, logs "end", then the script pops off.
  • Steps 11-13 drain microtasks: the event loop sees the stack is empty and picks up the .then callback first. Microtasks ALWAYS have priority.
  • Steps 14-16 process one macrotask: only after microtasks are fully drained does the event loop take the setTimeout callback.

The final log order is ["start", "end", "microtask", "timeout"]. This proves microtasks always beat macrotasks, regardless of registration order.

Why pre-computed snapshots instead of a real parser?

Building an actual JavaScript interpreter would take hours and shift your focus to AST parsing instead of event loop rules. The snapshot approach forces you to manually trace execution order. If you get the rules wrong, the visualization shows incorrect output. This is where the real learning happens.

Now wire up main.ts to call your scenario and log the results.

  • Replace the contents of src/main.ts with this code:
import { getScenarios } from "./engine";

const scenarios = getScenarios();
console.log(scenarios[0].snapshots);

What does this code do?

It imports getScenarios() from the engine, calls it to generate all scenario data, and logs the first scenario's snapshots array to the browser console. This lets you verify your event loop simulation is correct before building any UI.

  • Save both src/engine.ts and src/main.ts.
  • Open your browser at localhost:5173 and open the browser DevTools console (Cmd+Option+J on macOS or Ctrl+Shift+J on Windows).
  • You should see an array of 16 objects logged. Expand the last object and confirm its log property reads ["start", "end", "microtask", "timeout"].

Not seeing 16 snapshots?

If you see fewer snapshots or an error, check that the buildTimeoutVsPromiseScenario() function is placed after the snap() function. It must come below both helpers.

If the console shows an import error, make sure getScenarios is exported with the export keyword in src/engine.ts.

help me debug why my event loop scenario is not producing 16 snapshots

✔️ Awesome, I've got everything!

Great. Double check you saved both files before moving on.

ⓧ I'd like to double check the full code

import { Task, Snapshot, Scenario } from "./types";

let nextId = 0;

function task(label: string): Task {
  return { id: `task-${nextId++}`, label };
}

function snap(
  callStack: Task[],
  microtaskQueue: Task[],
  macrotaskQueue: Task[],
  log: string[],
  description: string,
  highlight: Snapshot["highlight"]
): Snapshot {
  return {
    callStack: [...callStack],
    microtaskQueue: [...microtaskQueue],
    macrotaskQueue: [...macrotaskQueue],
    log: [...log],
    description,
    highlight,
  };
}

function buildTimeoutVsPromiseScenario(): Scenario {
  nextId = 0;
  const stack: Task[] = [];
  const micro: Task[] = [];
  const macro: Task[] = [];
  const log: string[] = [];
  const snapshots: Snapshot[] = [];

  const script = task("script");
  stack.push(script);
  snapshots.push(snap(stack, micro, macro, log, "The script begins executing. It is pushed onto the call stack.", "callstack"));

  const logStart = task('console.log("start")');
  stack.push(logStart);
  snapshots.push(snap(stack, micro, macro, log, 'console.log("start") is called and pushed onto the call stack.', "callstack"));

  log.push("start");
  stack.pop();
  snapshots.push(snap(stack, micro, macro, log, '"start" is logged to the console. console.log pops off the stack.', "log"));

  const stCall = task("setTimeout(cb, 0)");
  stack.push(stCall);
  snapshots.push(snap(stack, micro, macro, log, "setTimeout is called and pushed onto the call stack.", "callstack"));

  stack.pop();
  const stCb = task("timeout callback");
  macro.push(stCb);
  snapshots.push(snap(stack, micro, macro, log, "setTimeout registers its callback in the macrotask queue (even with 0ms delay) and pops off the stack.", "macrotask"));

  const prCall = task("Promise.resolve().then(cb)");
  stack.push(prCall);
  snapshots.push(snap(stack, micro, macro, log, "Promise.resolve().then() is called and pushed onto the call stack.", "callstack"));

  stack.pop();
  const prCb = task(".then callback");
  micro.push(prCb);
  snapshots.push(snap(stack, micro, macro, log, "The Promise resolves immediately. Its .then callback is enqueued in the microtask queue.", "microtask"));

  const logEnd = task('console.log("end")');
  stack.push(logEnd);
  snapshots.push(snap(stack, micro, macro, log, 'console.log("end") is called and pushed onto the call stack.', "callstack"));

  log.push("end");
  stack.pop();
  snapshots.push(snap(stack, micro, macro, log, '"end" is logged to the console. console.log pops off the stack.', "log"));

  stack.pop();
  snapshots.push(snap(stack, micro, macro, log, "The script finishes. It pops off the call stack. The stack is now empty.", "callstack"));

  snapshots.push(snap(stack, micro, macro, log, "EVENT LOOP: Call stack is empty. Check microtask queue first. It has a task! Microtasks always have priority over macrotasks.", "microtask"));

  micro.shift();
  const prExec = task('() => log("microtask")');
  stack.push(prExec);
  snapshots.push(snap(stack, micro, macro, log, "The .then callback is dequeued from microtasks and pushed onto the call stack.", "callstack"));

  log.push("microtask");
  stack.pop();
  snapshots.push(snap(stack, micro, macro, log, '"microtask" is logged. Callback pops off. Microtask queue is now empty.', "log"));

  snapshots.push(snap(stack, micro, macro, log, "EVENT LOOP: Microtask queue empty. Now take ONE macrotask from the macrotask queue.", "macrotask"));

  macro.shift();
  const stExec = task('() => log("timeout")');
  stack.push(stExec);
  snapshots.push(snap(stack, micro, macro, log, "The setTimeout callback is dequeued from macrotasks and pushed onto the call stack.", "callstack"));

  log.push("timeout");
  stack.pop();
  snapshots.push(snap(stack, micro, macro, log, '"timeout" is logged. All queues empty. Event loop is idle. Final output: start, end, microtask, timeout.', "log"));

  return {
    title: "setTimeout vs Promise.then",
    code: `console.log("start");\nsetTimeout(() => console.log("timeout"), 0);\nPromise.resolve().then(() => console.log("microtask"));\nconsole.log("end");\n\n// Output: start, end, microtask, timeout`,
    snapshots,
  };
}

export function getScenarios(): Scenario[] {
  return [buildTimeoutVsPromiseScenario()];
}
import { getScenarios } from "./engine";

const scenarios = getScenarios();
console.log(scenarios[0].snapshots);

Your data model is complete. You have TypeScript types describing the event loop's state and a working scenario that proves the priority rules. Next up, you will build the visual UI that brings these snapshots to life with animated task cards and color-coded columns.

Build the Visual UI

Your data model and simulation engine are working. You confirmed the correct output order in the console last step.

Now it is time to give those snapshots a face. In this step, you will build the HTML layout, style it with color-coded task cards, and add a second scenario that proves microtasks drain completely before any macrotask runs.

In this step, get ready to:
  • Build the HTML layout with columns for each event loop component.
  • Write CSS styles with animated, color-coded task cards.
  • Add a second scenario demonstrating microtask chaining.
Replace the HTML with the visualizer layout

The visualizer needs a structured layout with dedicated zones for each part of the event loop. You will create a header with a scenario selector, a code display panel, four visual columns, a description bar, and navigation controls.

  • Open index.html in VS Code (it is in the top level of your event-loop-visualizer folder).
  • Select all existing content and replace it with the following:
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Event Loop Visualizer</title>
  </head>
  <body>
    <div id="app">
      <header class="header">
        <h1>Event Loop Visualizer</h1>
        <div class="controls-top">
          <select id="scenario-select"></select>
        </div>
      </header>

      <div class="code-panel">
        <pre><code id="code-display"></code></pre>
      </div>

      <div class="visualizer">
        <div class="column" id="col-callstack">
          <h2>Call Stack</h2>
          <div class="task-list" id="callstack-list"></div>
        </div>
        <div class="column" id="col-microtask">
          <h2>Microtask Queue</h2>
          <div class="task-list" id="microtask-list"></div>
        </div>
        <div class="column" id="col-macrotask">
          <h2>Macrotask Queue</h2>
          <div class="task-list" id="macrotask-list"></div>
        </div>
        <div class="column" id="col-log">
          <h2>Console</h2>
          <div class="console-output" id="console-output"></div>
        </div>
      </div>

      <div class="description-bar" id="description"></div>

      <div class="controls">
        <button id="btn-prev" type="button">Previous</button>
        <button id="btn-play" type="button">Play</button>
        <button id="btn-next" type="button">Next</button>
        <span id="step-counter">Step 0 of 0</span>
      </div>
    </div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

What does this layout do?

  • The #scenario-select dropdown will let the user switch between different code examples.
  • The #code-display panel shows the source code of the current scenario.
  • The four .column divs map directly to event loop components: call stack, microtask queue, macrotask queue, and console output.
  • The #description bar explains what the event loop is doing at each step.
  • The navigation buttons and step counter let the user move through snapshots.
  • Save index.html.
  • Refresh your browser at localhost:5173. You should see the text "Event Loop Visualizer" as a heading, the column headers (Call Stack, Microtask Queue, Macrotask Queue, Console), and three navigation buttons.

Page looks completely blank?

Make sure your <script> tag at the bottom has type="module" and the path /src/main.ts. Without type="module", Vite cannot process the TypeScript file.

Also confirm your dev server is still running in the terminal. If it stopped, run npm run dev again.

help me debug why my index.html shows a blank page

Write the stylesheet

The layout is there, but it is unstyled plain text. You will now add a stylesheet that uses CSS Grid for the four-column layout, color-codes task cards by type, and adds a slide-in animation for visual feedback.

  • Create a new file called src/style.css in the src folder.
  • Add the foundation styles (CSS variables, reset, and layout structure) by pasting in the following:
:root {
  --color-bg: #1a1a2e;
  --color-surface: #16213e;
  --color-border: #0f3460;
  --color-text: #e4e4e4;
  --color-sync: #4a9eff;
  --color-microtask: #a855f7;
  --color-macrotask: #f59e0b;
  --color-highlight: #22d3ee;
}

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: "JetBrains Mono", "Fira Code", monospace;
  background: var(--color-bg);
  color: var(--color-text);
  min-height: 100vh;
  padding: 1rem;
}

#app {
  max-width: 1200px;
  margin: 0 auto;
}

.header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 1rem;
}

.header h1 {
  font-size: 1.4rem;
}

#scenario-select {
  padding: 0.4rem 0.8rem;
  border-radius: 4px;
  border: 1px solid var(--color-border);
  background: var(--color-surface);
  color: var(--color-text);
  font-size: 0.9rem;
}

.code-panel {
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: 8px;
  padding: 1rem;
  margin-bottom: 1rem;
  overflow-x: auto;
}

.code-panel code {
  font-size: 0.85rem;
  white-space: pre;
  line-height: 1.5;
}

What do these variables and layout styles do?

  • The :root variables define a dark color palette. Each task type gets its own color: blue for sync, purple for microtask, amber for macrotask.
  • The universal reset removes browser defaults so spacing is predictable.
  • The .header uses flexbox to push the title left and the scenario selector right.
  • The .code-panel holds the source code preview with a surface background and rounded corners.
  • Now add the visualizer component styles below the code you just pasted:
.visualizer {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 0.75rem;
  margin-bottom: 1rem;
}

.column {
  background: var(--color-surface);
  border: 2px solid var(--color-border);
  border-radius: 8px;
  padding: 0.75rem;
  min-height: 200px;
  transition: border-color 0.3s ease;
}

.column.highlighted {
  border-color: var(--color-highlight);
  box-shadow: 0 0 12px rgba(34, 211, 238, 0.3);
}

.column h2 {
  font-size: 0.8rem;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  margin-bottom: 0.75rem;
  opacity: 0.7;
}

.task-list {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.task-card {
  padding: 0.5rem 0.75rem;
  border-radius: 6px;
  font-size: 0.75rem;
  font-weight: 500;
  animation: slideIn 0.3s ease;
  word-break: break-word;
}

.task-card.sync {
  background: rgba(74, 158, 255, 0.15);
  border: 1px solid var(--color-sync);
  color: var(--color-sync);
}

.task-card.microtask {
  background: rgba(168, 85, 247, 0.15);
  border: 1px solid var(--color-microtask);
  color: var(--color-microtask);
}

.task-card.macrotask {
  background: rgba(245, 158, 11, 0.15);
  border: 1px solid var(--color-macrotask);
  color: var(--color-macrotask);
}

@keyframes slideIn {
  from {
    opacity: 0;
    transform: translateY(-8px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.console-output {
  font-size: 0.8rem;
  line-height: 1.8;
}

.console-line {
  padding: 0.2rem 0.5rem;
  background: rgba(34, 211, 238, 0.08);
  border-radius: 3px;
  margin-bottom: 0.25rem;
}

.description-bar {
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: 8px;
  padding: 1rem;
  margin-bottom: 1rem;
  font-size: 0.9rem;
  line-height: 1.5;
  min-height: 60px;
}

.controls {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  justify-content: center;
}

.controls button {
  padding: 0.5rem 1.2rem;
  border-radius: 6px;
  border: 1px solid var(--color-border);
  background: var(--color-surface);
  color: var(--color-text);
  font-size: 0.85rem;
  cursor: pointer;
  transition: background 0.2s;
}

.controls button:hover {
  background: var(--color-border);
}

.controls button:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}

#step-counter {
  font-size: 0.8rem;
  opacity: 0.7;
}

What do these component styles do?

  • The .visualizer grid creates four equal-width columns, one for each event loop zone.
  • The .column.highlighted class adds a cyan glow effect. This will fire when the event loop focuses on that zone.
  • Each .task-card variant uses a semi-transparent background with a matching border. Blue for sync calls, purple for microtasks, amber for macrotasks.
  • The @keyframes slideIn animation slides cards in from above with a fade, giving visual feedback when tasks appear.

For the CSS to actually apply, you need to import the stylesheet into your application entry point.

  • Open src/main.ts and add this import at the very top of the file, above the existing import:
import "./style.css";

Why import CSS in a TypeScript file?

Vite uses JavaScript imports as the entry point for all assets. When you import a CSS file in your .ts file, Vite bundles and injects it into the page automatically. No <link> tag needed in the HTML.

  • Save both src/style.css and src/main.ts.
  • Refresh your browser. You should see a dark-themed page with four bordered columns side by side (Call Stack, Microtask Queue, Macrotask Queue, Console), a description bar below them, and centered navigation buttons at the bottom.

Still seeing unstyled plain text?

Make sure you added import "./style.css" at the very top of src/main.ts. Without this import, Vite does not know to include the stylesheet.

Also check that your CSS file is named exactly style.css inside the src folder. A mismatched filename or location causes a silent import failure.

help me fix my CSS not loading in Vite

Add the microtask chaining scenario

Your first scenario showed that microtasks beat macrotasks in priority. But what happens when a microtask itself queues another microtask? The event loop drains the entire microtask queue, including newly added entries, before touching any macrotask.

This second scenario proves that rule by chaining .then() callbacks that spawn more microtasks.

  • Open src/engine.ts. Find the getScenarios() function at the bottom of the file. Add the following buildMicrotaskChainScenario function directly above getScenarios():
function buildMicrotaskChainScenario(): Scenario {
  nextId = 0;
  const stack: Task[] = [];
  const micro: Task[] = [];
  const macro: Task[] = [];
  const log: string[] = [];
  const snapshots: Snapshot[] = [];

  // Script starts
  const script = task("script");
  stack.push(script);
  snapshots.push(snap(stack, micro, macro, log, "The script begins executing.", "callstack"));

  // setTimeout is called
  const stCall = task("setTimeout(cb, 0)");
  stack.push(stCall);
  snapshots.push(snap(stack, micro, macro, log, "setTimeout is called.", "callstack"));

  // setTimeout callback goes to macrotask queue
  stack.pop();
  const stCb = task("timeout callback");
  macro.push(stCb);
  snapshots.push(snap(stack, micro, macro, log, "setTimeout callback goes to the macrotask queue.", "macrotask"));

  // First Promise.then is called
  const pr1 = task("Promise.then(cb1)");
  stack.push(pr1);
  snapshots.push(snap(stack, micro, macro, log, "First Promise.then is called.", "callstack"));

  // cb1 enqueued as microtask
  stack.pop();
  const cb1 = task("cb1: log + chain");
  micro.push(cb1);
  snapshots.push(snap(stack, micro, macro, log, "cb1 is enqueued in the microtask queue.", "microtask"));

  // Script finishes
  stack.pop();
  snapshots.push(snap(stack, micro, macro, log, "Script finishes and pops off the call stack.", "callstack"));

  // Event loop starts draining microtasks
  snapshots.push(snap(stack, micro, macro, log, "EVENT LOOP: Stack empty. Draining microtask queue.", "microtask"));

  // cb1 executes
  micro.shift();
  const cb1Exec = task("cb1: log('micro1')");
  stack.push(cb1Exec);
  snapshots.push(snap(stack, micro, macro, log, "cb1 is dequeued and pushed onto the call stack.", "callstack"));

  // cb1 logs and chains another microtask
  log.push("micro1");
  const cb2 = task("cb2: log('micro2')");
  micro.push(cb2);
  stack.pop();
  snapshots.push(snap(stack, micro, macro, log, '"micro1" is logged. cb1 chains another .then, so cb2 is added to the microtask queue. KEY INSIGHT: The event loop does NOT move to macrotasks yet. It keeps draining microtasks.', "microtask"));

  // cb2 executes
  micro.shift();
  const cb2Exec = task("cb2: log('micro2')");
  stack.push(cb2Exec);
  snapshots.push(snap(stack, micro, macro, log, "cb2 is dequeued and pushed onto the call stack. Still draining microtasks!", "callstack"));

  // cb2 logs
  log.push("micro2");
  stack.pop();
  snapshots.push(snap(stack, micro, macro, log, '"micro2" is logged. Microtask queue is finally empty.', "log"));

  // Event loop moves to macrotask
  snapshots.push(snap(stack, micro, macro, log, "EVENT LOOP: Microtask queue fully drained. Now take one macrotask.", "macrotask"));

  // setTimeout callback executes
  macro.shift();
  const stExec = task('() => log("timeout")');
  stack.push(stExec);
  snapshots.push(snap(stack, micro, macro, log, "setTimeout callback is dequeued and pushed onto the call stack.", "callstack"));

  // Final log
  log.push("timeout");
  stack.pop();
  snapshots.push(snap(stack, micro, macro, log, '"timeout" is logged. Final output: micro1, micro2, timeout. Microtasks that spawn microtasks ALL run before any macrotask.', "log"));

  return {
    title: "Microtask Chaining",
    code: `setTimeout(() => console.log("timeout"), 0);\n\nPromise.resolve()\n  .then(() => {\n    console.log("micro1");\n    return Promise.resolve();\n  })\n  .then(() => console.log("micro2"));\n\n// Output: micro1, micro2, timeout`,
    snapshots,
  };
}

What rule does this scenario encode?

The event loop does not just run one microtask and then check macrotasks. It drains the entire microtask queue, including microtasks added during draining, before processing any macrotask.

This means cb1 logs "micro1" and queues cb2. The event loop sees cb2 waiting and runs it immediately, logging "micro2". Only then does the setTimeout callback get its turn.

  • Now update the getScenarios() function to return both scenarios. Find this line:
export function getScenarios(): Scenario[] {
  return [buildTimeoutVsPromiseScenario()];
}
  • Replace it with this updated version that includes both scenarios:
export function getScenarios(): Scenario[] {
  return [buildTimeoutVsPromiseScenario(), buildMicrotaskChainScenario()];
}
  • Finally, update the console.log in src/main.ts to log all scenarios instead of just the first one's snapshots. Find this line:
console.log(scenarios[0].snapshots);
  • Replace it with:
console.log(scenarios);
  • Save src/engine.ts and src/main.ts.
  • Open the browser console (Cmd+Option+J on macOS or Ctrl+Shift+J on Windows). You should see an array of two scenario objects. Expand the second one and verify its title is "Microtask Chaining" and the last snapshot's log array reads ["micro1", "micro2", "timeout"].

Only seeing one scenario in the console?

Make sure buildMicrotaskChainScenario is placed above getScenarios() in engine.ts. If the function is defined after it is called, TypeScript will throw a reference error.

Also check that getScenarios() returns an array with both functions: [buildTimeoutVsPromiseScenario(), buildMicrotaskChainScenario()].

help me debug why my second scenario is not showing up

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files: index.html, src/style.css, and src/engine.ts.

ⓧ I'd like to double check the full code

Here is the complete index.html:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Event Loop Visualizer</title>
  </head>
  <body>
    <div id="app">
      <header class="header">
        <h1>Event Loop Visualizer</h1>
        <div class="controls-top">
          <select id="scenario-select"></select>
        </div>
      </header>

      <div class="code-panel">
        <pre><code id="code-display"></code></pre>
      </div>

      <div class="visualizer">
        <div class="column" id="col-callstack">
          <h2>Call Stack</h2>
          <div class="task-list" id="callstack-list"></div>
        </div>
        <div class="column" id="col-microtask">
          <h2>Microtask Queue</h2>
          <div class="task-list" id="microtask-list"></div>
        </div>
        <div class="column" id="col-macrotask">
          <h2>Macrotask Queue</h2>
          <div class="task-list" id="macrotask-list"></div>
        </div>
        <div class="column" id="col-log">
          <h2>Console</h2>
          <div class="console-output" id="console-output"></div>
        </div>
      </div>

      <div class="description-bar" id="description"></div>

      <div class="controls">
        <button id="btn-prev" type="button">Previous</button>
        <button id="btn-play" type="button">Play</button>
        <button id="btn-next" type="button">Next</button>
        <span id="step-counter">Step 0 of 0</span>
      </div>
    </div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

Here is the complete src/style.css:

:root {
  --color-bg: #1a1a2e;
  --color-surface: #16213e;
  --color-border: #0f3460;
  --color-text: #e4e4e4;
  --color-sync: #4a9eff;
  --color-microtask: #a855f7;
  --color-macrotask: #f59e0b;
  --color-highlight: #22d3ee;
}

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: "JetBrains Mono", "Fira Code", monospace;
  background: var(--color-bg);
  color: var(--color-text);
  min-height: 100vh;
  padding: 1rem;
}

#app {
  max-width: 1200px;
  margin: 0 auto;
}

.header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 1rem;
}

.header h1 {
  font-size: 1.4rem;
}

#scenario-select {
  padding: 0.4rem 0.8rem;
  border-radius: 4px;
  border: 1px solid var(--color-border);
  background: var(--color-surface);
  color: var(--color-text);
  font-size: 0.9rem;
}

.code-panel {
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: 8px;
  padding: 1rem;
  margin-bottom: 1rem;
  overflow-x: auto;
}

.code-panel code {
  font-size: 0.85rem;
  white-space: pre;
  line-height: 1.5;
}

.visualizer {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 0.75rem;
  margin-bottom: 1rem;
}

.column {
  background: var(--color-surface);
  border: 2px solid var(--color-border);
  border-radius: 8px;
  padding: 0.75rem;
  min-height: 200px;
  transition: border-color 0.3s ease;
}

.column.highlighted {
  border-color: var(--color-highlight);
  box-shadow: 0 0 12px rgba(34, 211, 238, 0.3);
}

.column h2 {
  font-size: 0.8rem;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  margin-bottom: 0.75rem;
  opacity: 0.7;
}

.task-list {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.task-card {
  padding: 0.5rem 0.75rem;
  border-radius: 6px;
  font-size: 0.75rem;
  font-weight: 500;
  animation: slideIn 0.3s ease;
  word-break: break-word;
}

.task-card.sync {
  background: rgba(74, 158, 255, 0.15);
  border: 1px solid var(--color-sync);
  color: var(--color-sync);
}

.task-card.microtask {
  background: rgba(168, 85, 247, 0.15);
  border: 1px solid var(--color-microtask);
  color: var(--color-microtask);
}

.task-card.macrotask {
  background: rgba(245, 158, 11, 0.15);
  border: 1px solid var(--color-macrotask);
  color: var(--color-macrotask);
}

@keyframes slideIn {
  from {
    opacity: 0;
    transform: translateY(-8px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.console-output {
  font-size: 0.8rem;
  line-height: 1.8;
}

.console-line {
  padding: 0.2rem 0.5rem;
  background: rgba(34, 211, 238, 0.08);
  border-radius: 3px;
  margin-bottom: 0.25rem;
}

.description-bar {
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: 8px;
  padding: 1rem;
  margin-bottom: 1rem;
  font-size: 0.9rem;
  line-height: 1.5;
  min-height: 60px;
}

.controls {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  justify-content: center;
}

.controls button {
  padding: 0.5rem 1.2rem;
  border-radius: 6px;
  border: 1px solid var(--color-border);
  background: var(--color-surface);
  color: var(--color-text);
  font-size: 0.85rem;
  cursor: pointer;
  transition: background 0.2s;
}

.controls button:hover {
  background: var(--color-border);
}

.controls button:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}

#step-counter {
  font-size: 0.8rem;
  opacity: 0.7;
}

Here is the complete src/engine.ts:

import { Task, Snapshot, Scenario } from "./types";

let nextId = 0;

function task(label: string): Task {
  return { id: `task-${nextId++}`, label };
}

function snap(
  callStack: Task[],
  microtaskQueue: Task[],
  macrotaskQueue: Task[],
  log: string[],
  description: string,
  highlight: Snapshot["highlight"]
): Snapshot {
  return {
    callStack: [...callStack],
    microtaskQueue: [...microtaskQueue],
    macrotaskQueue: [...macrotaskQueue],
    log: [...log],
    description,
    highlight,
  };
}

function buildTimeoutVsPromiseScenario(): Scenario {
  nextId = 0;
  const stack: Task[] = [];
  const micro: Task[] = [];
  const macro: Task[] = [];
  const log: string[] = [];
  const snapshots: Snapshot[] = [];

  const script = task("script");
  stack.push(script);
  snapshots.push(snap(stack, micro, macro, log, "The script begins executing. It is pushed onto the call stack.", "callstack"));

  const logStart = task('console.log("start")');
  stack.push(logStart);
  snapshots.push(snap(stack, micro, macro, log, 'console.log("start") is called and pushed onto the call stack.', "callstack"));

  log.push("start");
  stack.pop();
  snapshots.push(snap(stack, micro, macro, log, '"start" is logged to the console. console.log pops off the stack.', "log"));

  const stCall = task("setTimeout(cb, 0)");
  stack.push(stCall);
  snapshots.push(snap(stack, micro, macro, log, "setTimeout is called and pushed onto the call stack.", "callstack"));

  stack.pop();
  const stCb = task("timeout callback");
  macro.push(stCb);
  snapshots.push(snap(stack, micro, macro, log, "setTimeout registers its callback in the macrotask queue (even with 0ms delay) and pops off the stack.", "macrotask"));

  const prCall = task("Promise.resolve().then(cb)");
  stack.push(prCall);
  snapshots.push(snap(stack, micro, macro, log, "Promise.resolve().then() is called and pushed onto the call stack.", "callstack"));

  stack.pop();
  const prCb = task(".then callback");
  micro.push(prCb);
  snapshots.push(snap(stack, micro, macro, log, "The Promise resolves immediately. Its .then callback is enqueued in the microtask queue.", "microtask"));

  const logEnd = task('console.log("end")');
  stack.push(logEnd);
  snapshots.push(snap(stack, micro, macro, log, 'console.log("end") is called and pushed onto the call stack.', "callstack"));

  log.push("end");
  stack.pop();
  snapshots.push(snap(stack, micro, macro, log, '"end" is logged to the console. console.log pops off the stack.', "log"));

  stack.pop();
  snapshots.push(snap(stack, micro, macro, log, "The script finishes. It pops off the call stack. The stack is now empty.", "callstack"));

  snapshots.push(snap(stack, micro, macro, log, "EVENT LOOP: Call stack is empty. Check microtask queue first. It has a task! Microtasks always have priority over macrotasks.", "microtask"));

  micro.shift();
  const prExec = task('() => log("microtask")');
  stack.push(prExec);
  snapshots.push(snap(stack, micro, macro, log, "The .then callback is dequeued from microtasks and pushed onto the call stack.", "callstack"));

  log.push("microtask");
  stack.pop();
  snapshots.push(snap(stack, micro, macro, log, '"microtask" is logged. Callback pops off. Microtask queue is now empty.', "log"));

  snapshots.push(snap(stack, micro, macro, log, "EVENT LOOP: Microtask queue empty. Now take ONE macrotask from the macrotask queue.", "macrotask"));

  macro.shift();
  const stExec = task('() => log("timeout")');
  stack.push(stExec);
  snapshots.push(snap(stack, micro, macro, log, "The setTimeout callback is dequeued from macrotasks and pushed onto the call stack.", "callstack"));

  log.push("timeout");
  stack.pop();
  snapshots.push(snap(stack, micro, macro, log, '"timeout" is logged. All queues empty. Event loop is idle. Final output: start, end, microtask, timeout.', "log"));

  return {
    title: "setTimeout vs Promise.then",
    code: `console.log("start");\nsetTimeout(() => console.log("timeout"), 0);\nPromise.resolve().then(() => console.log("microtask"));\nconsole.log("end");\n\n// Output: start, end, microtask, timeout`,
    snapshots,
  };
}

function buildMicrotaskChainScenario(): Scenario {
  nextId = 0;
  const stack: Task[] = [];
  const micro: Task[] = [];
  const macro: Task[] = [];
  const log: string[] = [];
  const snapshots: Snapshot[] = [];

  const script = task("script");
  stack.push(script);
  snapshots.push(snap(stack, micro, macro, log, "The script begins executing.", "callstack"));

  const stCall = task("setTimeout(cb, 0)");
  stack.push(stCall);
  snapshots.push(snap(stack, micro, macro, log, "setTimeout is called.", "callstack"));

  stack.pop();
  const stCb = task("timeout callback");
  macro.push(stCb);
  snapshots.push(snap(stack, micro, macro, log, "setTimeout callback goes to the macrotask queue.", "macrotask"));

  const pr1 = task("Promise.then(cb1)");
  stack.push(pr1);
  snapshots.push(snap(stack, micro, macro, log, "First Promise.then is called.", "callstack"));

  stack.pop();
  const cb1 = task("cb1: log + chain");
  micro.push(cb1);
  snapshots.push(snap(stack, micro, macro, log, "cb1 is enqueued in the microtask queue.", "microtask"));

  stack.pop();
  snapshots.push(snap(stack, micro, macro, log, "Script finishes and pops off the call stack.", "callstack"));

  snapshots.push(snap(stack, micro, macro, log, "EVENT LOOP: Stack empty. Draining microtask queue.", "microtask"));

  micro.shift();
  const cb1Exec = task("cb1: log('micro1')");
  stack.push(cb1Exec);
  snapshots.push(snap(stack, micro, macro, log, "cb1 is dequeued and pushed onto the call stack.", "callstack"));

  log.push("micro1");
  const cb2 = task("cb2: log('micro2')");
  micro.push(cb2);
  stack.pop();
  snapshots.push(snap(stack, micro, macro, log, '"micro1" is logged. cb1 chains another .then, so cb2 is added to the microtask queue. KEY INSIGHT: The event loop does NOT move to macrotasks yet. It keeps draining microtasks.', "microtask"));

  micro.shift();
  const cb2Exec = task("cb2: log('micro2')");
  stack.push(cb2Exec);
  snapshots.push(snap(stack, micro, macro, log, "cb2 is dequeued and pushed onto the call stack. Still draining microtasks!", "callstack"));

  log.push("micro2");
  stack.pop();
  snapshots.push(snap(stack, micro, macro, log, '"micro2" is logged. Microtask queue is finally empty.', "log"));

  snapshots.push(snap(stack, micro, macro, log, "EVENT LOOP: Microtask queue fully drained. Now take one macrotask.", "macrotask"));

  macro.shift();
  const stExec = task('() => log("timeout")');
  stack.push(stExec);
  snapshots.push(snap(stack, micro, macro, log, "setTimeout callback is dequeued and pushed onto the call stack.", "callstack"));

  log.push("timeout");
  stack.pop();
  snapshots.push(snap(stack, micro, macro, log, '"timeout" is logged. Final output: micro1, micro2, timeout. Microtasks that spawn microtasks ALL run before any macrotask.', "log"));

  return {
    title: "Microtask Chaining",
    code: `setTimeout(() => console.log("timeout"), 0);\n\nPromise.resolve()\n  .then(() => {\n    console.log("micro1");\n    return Promise.resolve();\n  })\n  .then(() => console.log("micro2"));\n\n// Output: micro1, micro2, timeout`,
    snapshots,
  };
}

export function getScenarios(): Scenario[] {
  return [buildTimeoutVsPromiseScenario(), buildMicrotaskChainScenario()];
}

Here is the complete src/main.ts:

import "./style.css";
import { getScenarios } from "./engine";

const scenarios = getScenarios();
console.log(scenarios);

Your visualizer has a proper layout, styled columns, and two working scenarios. Next up, you will wire the UI to the engine so clicking through steps animates task cards across the columns in real time.

Wire Up Interactivity

Your visualizer has a beautiful layout and a simulation engine that knows exactly how the event loop processes code. Now it's time to connect the two.

In this step, you'll build the rendering logic that translates each snapshot into visible DOM changes, then wire up navigation so you can step through the simulation forwards and backwards.

In this step, get ready to:
  • Implement a render function that updates all four columns from a single snapshot.
  • Add event listeners for Previous, Next, and the scenario dropdown.
  • Add auto-play that advances one step every 1.5 seconds.
Implement the render function

The render function is the bridge between your data model and the browser. It receives a single Snapshot object and updates every column, the console output, the description bar, and the highlighted zone.

  • Replace the entire contents of src/main.ts with the following imports, state variables, and DOM element references:
import "./style.css";
import { Snapshot, Scenario } from "./types";
import { getScenarios } from "./engine";

// Load all scenario data from the engine
const scenarios: Scenario[] = getScenarios();
let currentScenarioIndex = 0;
let currentStep = 0;
let playInterval: number | null = null;

// Cache DOM element references for fast updates
const select = document.getElementById("scenario-select") as HTMLSelectElement;
const codeDisplay = document.getElementById("code-display") as HTMLElement;
const callstackList = document.getElementById("callstack-list") as HTMLElement;
const microtaskList = document.getElementById("microtask-list") as HTMLElement;
const macrotaskList = document.getElementById("macrotask-list") as HTMLElement;
const consoleOutput = document.getElementById("console-output") as HTMLElement;
const description = document.getElementById("description") as HTMLElement;
const btnPrev = document.getElementById("btn-prev") as HTMLButtonElement;
const btnNext = document.getElementById("btn-next") as HTMLButtonElement;
const btnPlay = document.getElementById("btn-play") as HTMLButtonElement;
const stepCounter = document.getElementById("step-counter") as HTMLElement;
const colCallstack = document.getElementById("col-callstack") as HTMLElement;
const colMicrotask = document.getElementById("col-microtask") as HTMLElement;
const colMacrotask = document.getElementById("col-macrotask") as HTMLElement;
const colLog = document.getElementById("col-log") as HTMLElement;

What does this code do?

The top section imports the CSS (so Vite injects it), your type definitions, and the scenario generator.

The state variables track which scenario is active, which step is displayed, and whether auto-play is running. The DOM references are cached once at load time so you don't call getElementById repeatedly during rendering.

  • Add the following rendering helper functions below the DOM references:
function populateSelect(): void {
  scenarios.forEach((scenario, index) => {
    const option = document.createElement("option");
    option.value = String(index);
    option.textContent = scenario.title;
    select.appendChild(option);
  });
}

function getTaskColorClass(columnId: string): string {
  if (columnId === "callstack-list") return "sync";
  if (columnId === "microtask-list") return "microtask";
  if (columnId === "macrotask-list") return "macrotask";
  return "sync";
}

function renderTaskList(container: HTMLElement, tasks: Snapshot["callStack"]): void {
  container.innerHTML = "";
  const colorClass = getTaskColorClass(container.id);
  tasks.forEach((t) => {
    const card = document.createElement("div");
    card.className = `task-card ${colorClass}`;
    card.textContent = t.label;
    container.appendChild(card);
  });
}

function renderConsole(log: string[]): void {
  consoleOutput.innerHTML = "";
  log.forEach((entry) => {
    const line = document.createElement("div");
    line.className = "console-line";
    line.textContent = `> ${entry}`;
    consoleOutput.appendChild(line);
  });
}

What do these helpers do?

  • populateSelect() fills the dropdown with one option per scenario.
  • getTaskColorClass() maps a container's ID to the matching CSS color class (sync, microtask, or macrotask).
  • renderTaskList() clears a column and creates a color-coded card element for each task.
  • renderConsole() rebuilds the console panel with one line per logged value.
  • Add the highlight management and main render function below the rendering helpers:
function clearHighlights(): void {
  colCallstack.classList.remove("highlighted");
  colMicrotask.classList.remove("highlighted");
  colMacrotask.classList.remove("highlighted");
  colLog.classList.remove("highlighted");
}

function applyHighlight(highlight: Snapshot["highlight"]): void {
  clearHighlights();
  if (highlight === "callstack") colCallstack.classList.add("highlighted");
  if (highlight === "microtask") colMicrotask.classList.add("highlighted");
  if (highlight === "macrotask") colMacrotask.classList.add("highlighted");
  if (highlight === "log") colLog.classList.add("highlighted");
}

function render(snapshot: Snapshot): void {
  renderTaskList(callstackList, snapshot.callStack);
  renderTaskList(microtaskList, snapshot.microtaskQueue);
  renderTaskList(macrotaskList, snapshot.macrotaskQueue);
  renderConsole(snapshot.log);
  description.textContent = snapshot.description;
  applyHighlight(snapshot.highlight);
}

What does render() do?

The render() function is the single entry point for updating the UI. It delegates to each helper: task lists get rebuilt, the console refreshes, the description updates, and exactly one column gets the glowing highlight border to show where the event loop is currently acting.

  • Save src/main.ts.

You won't see a visible change yet because no function has been called to trigger a render. The next chunk wires up navigation to call render() with real snapshots.

Seeing a TypeScript error?

If VS Code underlines Snapshot as unused, that's fine for now. It becomes used once the render function references it in the function signature. If you see a different error, check that the import path uses "./types" (with the leading dot and no file extension).

Help me debug a TypeScript import error in main.ts

Add event listeners for navigation

With rendering in place, you need functions to control which snapshot is displayed and respond to button clicks.

  • Add the following navigation functions below the render() function:
function updateControls(): void {
  const scenario = scenarios[currentScenarioIndex];
  const total = scenario.snapshots.length;
  btnPrev.disabled = currentStep === 0;
  btnNext.disabled = currentStep === total - 1;
  stepCounter.textContent = `Step ${currentStep + 1} of ${total}`;
}

function goToStep(step: number): void {
  const scenario = scenarios[currentScenarioIndex];
  currentStep = Math.max(0, Math.min(step, scenario.snapshots.length - 1));
  render(scenario.snapshots[currentStep]);
  updateControls();
}

function loadScenario(index: number): void {
  currentScenarioIndex = index;
  currentStep = 0;
  const scenario = scenarios[index];
  codeDisplay.textContent = scenario.code;
  stopAutoPlay();
  goToStep(0);
}

What do these functions do?

  • updateControls() disables Previous on step 0, disables Next on the last step, and updates the step counter text.
  • goToStep() clamps the step index to valid bounds, renders that snapshot, and refreshes the button states.
  • loadScenario() resets to step 0, displays the scenario's source code in the code panel, stops any running auto-play, and renders the initial state.
  • Add the event listeners and initialization calls at the bottom of the file:
select.addEventListener("change", () => {
  loadScenario(Number(select.value));
});

btnPrev.addEventListener("click", () => {
  goToStep(currentStep - 1);
});

btnNext.addEventListener("click", () => {
  goToStep(currentStep + 1);
});

populateSelect();
loadScenario(0);

What's happening here?

Each listener calls the matching navigation function. The two init calls at the bottom populate the dropdown and load the first scenario, which triggers the first render. The loadScenario function references stopAutoPlay which you haven't written yet. TypeScript function declarations are hoisted, so defining it in the next chunk is safe.

  • Save src/main.ts and refresh your browser.
  • You should see the scenario dropdown populated with "setTimeout vs Promise.then", the code panel showing the source code, the first snapshot rendered (a blue "script" card in the Call Stack column), and the description bar explaining what just happened.
  • Click Next a few times to step through the simulation.

Nothing rendering on the page?

Open your browser console and check for errors. The most common issue is a missing stopAutoPlay function. If you see "stopAutoPlay is not defined", make sure all your functions use the function keyword (not const arrow functions), since only function declarations get hoisted.

Help me fix my event loop visualizer rendering issue

Add auto-play functionality

Stepping manually is useful for studying individual transitions. Auto-play lets you watch the full event loop cycle unfold like an animation, advancing one step every 1.5 seconds.

  • In src/main.ts, find the loadScenario function. Add the following two functions directly below it (before the event listeners):
function stopAutoPlay(): void {
  if (playInterval !== null) {
    clearInterval(playInterval);
    playInterval = null;
    btnPlay.textContent = "Play";
  }
}

function toggleAutoPlay(): void {
  if (playInterval !== null) {
    stopAutoPlay();
  } else {
    btnPlay.textContent = "Pause";
    playInterval = window.setInterval(() => {
      const scenario = scenarios[currentScenarioIndex];
      if (currentStep >= scenario.snapshots.length - 1) {
        stopAutoPlay();
        return;
      }
      goToStep(currentStep + 1);
    }, 1500);
  }
}

What does this code do?

stopAutoPlay() clears the interval timer and resets the button label to "Play". toggleAutoPlay() either stops a running interval or starts a new one that calls goToStep(currentStep + 1) every 1500 milliseconds.

When the animation reaches the last snapshot, it stops itself automatically. This prevents the interval from running forever after the scenario finishes.

  • Now find the btnNext event listener. Add the Play button listener directly below it (before the populateSelect() init call):
btnPlay.addEventListener("click", toggleAutoPlay);
  • Save src/main.ts and refresh your browser.
  • Click Play. You should see the task cards animate through the scenario automatically, advancing every 1.5 seconds.
  • The button text changes to "Pause" while playing. Click it again to stop.
  • Click through the entire "setTimeout vs Promise.then" scenario using Next. Confirm the final console shows: start, end, microtask, timeout.
  • Switch to the "Microtask Chaining" scenario using the dropdown. Confirm it resets to step 0.

Play button not working?

Make sure btnPlay.addEventListener("click", toggleAutoPlay) is a separate line below the btnNext listener. If the button does nothing, check that playInterval is declared as number | null at the top of the file and initialized to null.

Help me debug my auto-play feature

✔️ Awesome, I've got everything!

Great. Double check you've saved src/main.ts and that clicking through both scenarios works as expected.

ⓧ I'd like to double check the full code

Here is the complete src/main.ts file:

import "./style.css";
import { Snapshot, Scenario } from "./types";
import { getScenarios } from "./engine";

const scenarios: Scenario[] = getScenarios();
let currentScenarioIndex = 0;
let currentStep = 0;
let playInterval: number | null = null;

const select = document.getElementById("scenario-select") as HTMLSelectElement;
const codeDisplay = document.getElementById("code-display") as HTMLElement;
const callstackList = document.getElementById("callstack-list") as HTMLElement;
const microtaskList = document.getElementById("microtask-list") as HTMLElement;
const macrotaskList = document.getElementById("macrotask-list") as HTMLElement;
const consoleOutput = document.getElementById("console-output") as HTMLElement;
const description = document.getElementById("description") as HTMLElement;
const btnPrev = document.getElementById("btn-prev") as HTMLButtonElement;
const btnNext = document.getElementById("btn-next") as HTMLButtonElement;
const btnPlay = document.getElementById("btn-play") as HTMLButtonElement;
const stepCounter = document.getElementById("step-counter") as HTMLElement;
const colCallstack = document.getElementById("col-callstack") as HTMLElement;
const colMicrotask = document.getElementById("col-microtask") as HTMLElement;
const colMacrotask = document.getElementById("col-macrotask") as HTMLElement;
const colLog = document.getElementById("col-log") as HTMLElement;

function populateSelect(): void {
  scenarios.forEach((scenario, index) => {
    const option = document.createElement("option");
    option.value = String(index);
    option.textContent = scenario.title;
    select.appendChild(option);
  });
}

function getTaskColorClass(columnId: string): string {
  if (columnId === "callstack-list") return "sync";
  if (columnId === "microtask-list") return "microtask";
  if (columnId === "macrotask-list") return "macrotask";
  return "sync";
}

function renderTaskList(container: HTMLElement, tasks: Snapshot["callStack"]): void {
  container.innerHTML = "";
  const colorClass = getTaskColorClass(container.id);
  tasks.forEach((t) => {
    const card = document.createElement("div");
    card.className = `task-card ${colorClass}`;
    card.textContent = t.label;
    container.appendChild(card);
  });
}

function renderConsole(log: string[]): void {
  consoleOutput.innerHTML = "";
  log.forEach((entry) => {
    const line = document.createElement("div");
    line.className = "console-line";
    line.textContent = `> ${entry}`;
    consoleOutput.appendChild(line);
  });
}

function clearHighlights(): void {
  colCallstack.classList.remove("highlighted");
  colMicrotask.classList.remove("highlighted");
  colMacrotask.classList.remove("highlighted");
  colLog.classList.remove("highlighted");
}

function applyHighlight(highlight: Snapshot["highlight"]): void {
  clearHighlights();
  if (highlight === "callstack") colCallstack.classList.add("highlighted");
  if (highlight === "microtask") colMicrotask.classList.add("highlighted");
  if (highlight === "macrotask") colMacrotask.classList.add("highlighted");
  if (highlight === "log") colLog.classList.add("highlighted");
}

function render(snapshot: Snapshot): void {
  renderTaskList(callstackList, snapshot.callStack);
  renderTaskList(microtaskList, snapshot.microtaskQueue);
  renderTaskList(macrotaskList, snapshot.macrotaskQueue);
  renderConsole(snapshot.log);
  description.textContent = snapshot.description;
  applyHighlight(snapshot.highlight);
}

function updateControls(): void {
  const scenario = scenarios[currentScenarioIndex];
  const total = scenario.snapshots.length;
  btnPrev.disabled = currentStep === 0;
  btnNext.disabled = currentStep === total - 1;
  stepCounter.textContent = `Step ${currentStep + 1} of ${total}`;
}

function goToStep(step: number): void {
  const scenario = scenarios[currentScenarioIndex];
  currentStep = Math.max(0, Math.min(step, scenario.snapshots.length - 1));
  render(scenario.snapshots[currentStep]);
  updateControls();
}

function loadScenario(index: number): void {
  currentScenarioIndex = index;
  currentStep = 0;
  const scenario = scenarios[index];
  codeDisplay.textContent = scenario.code;
  stopAutoPlay();
  goToStep(0);
}

function stopAutoPlay(): void {
  if (playInterval !== null) {
    clearInterval(playInterval);
    playInterval = null;
    btnPlay.textContent = "Play";
  }
}

function toggleAutoPlay(): void {
  if (playInterval !== null) {
    stopAutoPlay();
  } else {
    btnPlay.textContent = "Pause";
    playInterval = window.setInterval(() => {
      const scenario = scenarios[currentScenarioIndex];
      if (currentStep >= scenario.snapshots.length - 1) {
        stopAutoPlay();
        return;
      }
      goToStep(currentStep + 1);
    }, 1500);
  }
}

select.addEventListener("change", () => {
  loadScenario(Number(select.value));
});

btnPrev.addEventListener("click", () => {
  goToStep(currentStep - 1);
});

btnNext.addEventListener("click", () => {
  goToStep(currentStep + 1);
});

btnPlay.addEventListener("click", toggleAutoPlay);

populateSelect();
loadScenario(0);

Your event loop visualizer is fully interactive. You can step forward, step backward, auto-play, and switch between scenarios. Next up, you'll tackle the Secret Mission and build a third scenario that reveals how async/await desugars into microtasks.

Secret mission

Add an Async/Await Scenario

You've mastered setTimeout vs Promise and microtask chaining. Now tackle the trickiest case: async/await desugaring. Each await creates a suspension point where the function yields and its continuation is queued as a microtask. Can you model the execution of multiple awaits and prove that synchronous code after the function call runs before any await continuation?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • The Vite dev server (running process in your terminal).
  • Your project folder on the Desktop (contains all source code and node_modules).

Keep everything running

No action needed. Choose this if you're still actively building or experimenting with new scenarios.

  • Your dev server stays running at localhost:5173.
  • All project files remain on your Desktop.
  • Node.js and VS Code remain installed on your machine (they were prerequisites, not project-specific installs).

Pause - I'll come back to this later

Shut down the running process to free up your terminal. Your files stay on disk so you can resume anytime.

  • In the terminal where the dev server is running, press Ctrl+C to stop it.
  • To resume later, open a terminal in your project folder and run npm run dev.

Delete - I don't want to use this again

Remove all project files and start fresh. This permanently deletes your code.

  • In the terminal where the dev server is running, press Ctrl+C to stop it.
  • Navigate to your Desktop and delete the project folder by running:
cd ~/Desktop
rm -rf [[PROJECT_FOLDER="your project folder name"]]

On Windows, use this instead:

cd ~\Desktop
Remove-Item -Recurse -Force [[PROJECT_FOLDER="your project folder name"]]
  • Confirm deletion by running ls (macOS/Linux) or dir (Windows) and verifying your project folder no longer appears.

Nice Work!

Nice Work!

Nice work! You just built a fully interactive event loop visualizer from scratch in TypeScript.

You've learned how to:

  • Build a TypeScript type system that models the JavaScript event loop (call stack, microtask queue, macrotask queue) and generates immutable state snapshots.
  • Implement a simulation engine that encodes the event loop's priority rules as step-by-step scenarios, proving that synchronous code runs first, all microtasks drain next, then one macrotask is processed.
  • Create an interactive visual UI with color-coded task cards, CSS animations, auto-play controls, and a scenario selector that lets you step through async execution in real time.
  • Secret Mission: Visualized async/await desugaring, showing how each await suspends the async function and queues its continuation as a microtask.

Ready to quiz yourself?