DEV Community

Cover image for How I Made JavaScript Execution Visual and Rewindable (DSA View View πŸ‘€πŸ‘€)
nyaomaru
nyaomaru

Posted on

How I Made JavaScript Execution Visual and Rewindable (DSA View View πŸ‘€πŸ‘€)

Rewrites code to record states

Hoi hoi!

I'm @nyaomaru, a frontend engineer who was recently knocked off my feet by how good the zeetong (sole) from a Dutch fish shop was. 🐟😸

In the previous article, I explained how DSA View View takes the TypeScript written by a user, analyzes the function or class signature, and generates a Verification form.

This time, we are entering the heart of DSA View View: the Runtime and Visualization layers.

If you call an ordinary JavaScript function, the direct result you get back is usually the final return value.

But when I am studying DSA, that is not the part I really want to see.

I want to know:

  • What happened in the middle of the loop?
  • When did the pointer move?
  • At which exact moment did the variable change?
  • How deep did the recursion go?

In other words, I want to see the journey to the answer, not only the answer itself.

JavaScript, unfortunately, does not provide an API that accepts:

"Excuse me, could you move the current line one step backward?"

So how does DSA View View rewind execution?

Here is the answer up front. 😸

Before execution, DSA View View rewrites the code into a version that can record its intermediate state, then runs it until it completes or reaches a configured limit.

The UI then plays those recorded states forward and backward like security-camera footage.

In this article, we will look at that record-and-replay system through:

  • AST transformation with Babel
  • Injecting recordStep calls
  • Preserving the original meaning of the code
  • ExecutionStep and snapshot patches
  • Rewinding and jumping to variable changes
  • Running code inside a Web Worker
  • Detecting the appropriate Visualization
  • Tests that protect the AST transformation

The source code is available here πŸ‘‡

GitHub logo nyaomaru / dsa-view-view

DSA View View allows you to understand DSA to see the data flow. πŸ‘€πŸ‘€ Of course, it's free.

DSA View View

DSA View View

DSA View View turns TypeScript algorithm functions into step-by-step visual stories. πŸ‘€πŸ‘€

DSA View View TV

Write code, run it with structured inputs, and see the arrays, matrices trees, lists, stacks, pointers, and return values move as the function executes.

It is built for those moments when reading the code is not enough and you want to view why the answer changes.

DSA View View demo

Why Try It?

  • 🧠 Step through real TypeScript
    Paste or edit a function, validate it, then run the exact code in the browser.

  • 🧩 Views that match the data
    Arrays become bars, matrices become grids, trees become node graphs, linked lists become chains, and two-pointer area problems get their own visual view.

  • 🌳 DSA-friendly inputs out of the box
    TreeNode, ListNode, MinHeap, MaxHeap, PriorityQueue, nested arrays matrices, strings, numbers, and class-style inputs are supported without ceremony.

  • πŸ”Ž 39 built-in examples
    Search by name, browse…

All right, let’s open the Runtime and see what Mr. View has been watching. πŸ‘€πŸ‘€


πŸ“ The Big Picture: Turning Execution into an Array of Steps

The Runtime pipeline looks roughly like this.

User TypeScript
      ↓
Prepare common DSA classes
      ↓
Parse the code into an AST with Babel Parser
      ↓
Visitors inject recordStep(...)
      ↓
Transform TypeScript into JavaScript with Babel
      ↓
Execute it with new Function inside a Web Worker
      ↓
Generate ExecutionStep[]
      ↓
React moves currentStep forward and backward
      ↓
Display the Visualization that matches the trace
Enter fullscreen mode Exit fullscreen mode

The most important design decision here is that the user’s code is not connected directly to a React component.

Instead, ExecutionStep[] acts as a shared recording format.

That lets arrays, pointers, recursion, trees, heaps, and other structures all live on the same timeline.

Let’s walk through how that works.


πŸ”­ Runtime: Planting Observation Points in the Code

This is the heart of DSA View View. πŸ’–

The Runtime needs intermediate states, so it does not execute the user’s code exactly as written.

First, Babel transforms the AST and injects calls to recordStep.

Injecting recordStep into the AST

Suppose the user writes.

let left = 0;

while (left < right) {
  left++;
}
Enter fullscreen mode Exit fullscreen mode

DSA View View parses it into an AST and transforms it into something roughly like this.

let left = 0;
recordStep("variable-declaration", 1, "let left = 0", { left });

while (left < right) {
  recordStep("loop-iteration", 3, "while (left < right)", { left, right });

  left++;
  recordStep("assignment", 4, "left++", { left, right });
}
Enter fullscreen mode Exit fullscreen mode

The second argument to recordStep is the original source line number, not the execution-step number.
Because line 2 is empty, the recorded lines are 1, 3, and 4.

In the real implementation, dedicated visitors handle AST nodes such as:

  • FunctionDeclaration
  • FunctionExpression
  • ArrowFunctionExpression
  • ClassMethod
  • VariableDeclaration
  • AssignmentExpression
  • UpdateExpression
  • CallExpression nodes that mutate arrays
  • for / while / for...of / for...in
  • ReturnStatement

A step can therefore be recorded when:

  • A function is entered
  • A variable is declared
  • A loop completes an iteration
  • A value is assigned
  • An array is mutated
  • A function returns

Current limitation
callback bodies inside methods such as sort, reduce, map, filter, and forEach are intentionally excluded from instrumentation for now.

The goal is not to trace every corner of JavaScript immediately. I am starting with patterns that are useful for DSA traces and can be instrumented without breaking the original behavior.

Injecting the Code Is Easier Than Not Breaking It

The hardest part of the AST transformation was not calling recordStep.

The hard part was making sure that,

after adding the recording logic, every original expression is still evaluated the same number of times and still returns the same value.

For example, if a return expression were evaluated once for recording and once for the actual return, its side effects would run twice.

return someFunction();
Enter fullscreen mode Exit fullscreen mode

So the transformed code first stores the value in a temporary variable.

const algorithmVisualizerReturnValue = someFunction();

recordStep("return", line, description, {
  "return value": algorithmVisualizerReturnValue,
  "return location": returnLocation,
});

return algorithmVisualizerReturnValue;
Enter fullscreen mode Exit fullscreen mode

Assignment expressions are another fun little trap.

Consider this.

const result = (arr[i++] = 7);
Enter fullscreen mode Exit fullscreen mode

The transformed code must preserve all three facts:

  • i++ runs exactly once
  • The pre-increment value of i is used as the array index
  • The assignment expression itself evaluates to 7

DSA View View wraps the assignment in a synthetic arrow function, stores the result in a temporary variable, records the step, and returns the same value.

Using an arrow function also avoids accidentally stealing the surrounding this or arguments.

AST transformation cannot be "close enough".

If adding more visible steps changes the answer, the visualizer has somehow become the least trustworthy character in the story.

Not ideal.


πŸ–οΈ ExecutionStep: Making Execution Replayable

The Runtime records each observed state as an ExecutionStep.

A shortened version of the real type looks like this.

type ExecutionStep = {
  stepNumber: number;
  type: ExecutionStepType;
  line: number;
  column?: number;
  description: string;
  variables: Record<string, unknown>;
  timestamp: number;
  callStack?: string[];
  scope?: string;
  metadata?: {
    loopIteration?: number;
    conditionResult?: boolean;
    functionName?: string;
    heapTrace?: HeapTraceSnapshot;
  };
};
Enter fullscreen mode Exit fullscreen mode

The Runtime currently generates steps including:

  • function-call
  • function-entry
  • variable-declaration
  • assignment
  • array-mutation
  • loop-iteration
  • return

line and description are used in the timeline.

variables provides the data needed to reconstruct the UI at that moment.

Recursive traces also use callStack, while prepared heap implementations can attach metadata.heapTrace.

Saving References Would Rewrite the Past

Suppose an array reference were stored directly inside a step.

const stack = [0, 1];
recordStep(/* save stack */);

stack.pop();
Enter fullscreen mode Exit fullscreen mode

If the recorded step keeps the same reference, the earlier state will also appear as [0] after pop() runs.

That is not a historical record.

That is the past quietly editing itself.

So values passed to recordStep are deep-cloned at that moment.

The Runtime normally uses structuredClone. When it encounters values that need different handling, it falls back to a cloning implementation that supports cycles, Map, Set, Date, RegExp, and other cases.

The "Delta Snapshot" Is Really More Like a Patch

The implementation contains a name called variableDeltas, but this deserves a more precise explanation.

The current Runtime does not compare every value with the previous state and store only the keys that truly changed.

Instead:

  1. The first step stores the initial state, including the inputs
  2. Later steps deep-clone the visible variables passed to that recordStep
  3. When a full snapshot is needed, the patches are merged in order
  4. Reconstructed snapshots are cached

So, strictly speaking, these are closer to per-step patches than minimal deltas.

Step 0 patch: { left: 0, right: 5 }
Step 1 patch: { left: 1 }
Step 2 patch: { right: 4 }
Enter fullscreen mode Exit fullscreen mode

Reconstructing them produces.

Step 0 snapshot: { left: 0, right: 5 }
Step 1 snapshot: { left: 1, right: 5 }
Step 2 snapshot: { left: 1, right: 4 }
Enter fullscreen mode Exit fullscreen mode

The variables property of each ExecutionStep is a getter.

It starts from a previously cached snapshot and merges only the patches needed to reach the requested step.

There is one more implementation detail worth mentioning.

The Runtime sends state from the Worker to the UI with postMessage. During that structured clone, reading the getter also materializes the snapshot.

So the data does not remain a tiny delta forever and magically arrive on the main thread in that form.

The design mainly reduces eager reconstruction and cloning while the execution is being recorded. πŸ“½οΈ

Why Can the Timeline Move Backward?

DSA View View is not reversing JavaScript while it runs.

Inside the Web Worker, it first rewrites the code into an instrumented version and records the execution.

During playback, the main value that changes is currentStep.

type ExecutionState = {
  currentStep: number;
  totalSteps: number;
  steps: ExecutionStep[];
  isComplete: boolean;
  returnValue?: unknown;
  error?: string;
};
Enter fullscreen mode Exit fullscreen mode

Moving forward displays the snapshot for currentStep + 1.

Moving backward displays the snapshot for currentStep - 1.

So rewinding means,

selecting an earlier recorded frame, not undoing the execution itself

Once recording has finished, users can navigate without rerunning their code:

  • Pause
  • Move one step forward or backward
  • Jump to any step in the timeline
  • Move to the first or final step

Users can also select a variable and jump directly to the previous step where its value changed.

That is especially useful for pointers such as i, left, and right.

No more clicking the Back button 47 times while whispering, "Where did you go wrong?" 😸

There Is a Generator, but It Does Not Suspend the User’s Code Line by Line

The Runtime API contains a Generator<ExecutionStep, ...>.

That might make it look as if the user’s code itself is being paused line by line with yield.

It is not.

After yielding the first function-call step, the instrumented code runs once and accumulates steps inside the execution context.

The generator then yields the already-recorded steps in order.

In the path used by the UI, the Worker consumes the generator completely and returns the finished ExecutionState.

Separating execution from playback is what makes the rewind behavior relatively simple.


βš™οΈ Web Worker: Do Not Take the UI Down with You

User code runs inside a dedicated Web Worker instead of the main thread.

new Worker(new URL("./execution-worker.ts", import.meta.url), {
  type: "module",
  name: "algorithm-execution",
});
Enter fullscreen mode Exit fullscreen mode

An infinite loop or expensive computation on the main thread would freeze the entire page.

By moving execution into a Worker:

  • The React UI stays on the main thread
  • Starting a new run or leaving the page can terminate the Worker through an AbortController

The Runtime also has limits:

  • Execution timeout: 10 seconds
  • Maximum recorded steps: 3,000

When a limit is reached, the Worker can be terminated or execution can stop early with a warning.

A Worker Is Not a Security Sandbox

A Web Worker does not have access to the page DOM, localStorage, or sessionStorage.

DSA View View also does not execute user code on a server.

However, a Web Worker is not a security boundary that makes untrusted code safe.

The user code runs through new Function in the Worker global scope.

Web APIs available inside Workers, including fetch, are not automatically disabled.

The purpose of this architecture is to:

  • Isolate heavy execution from the main thread
  • Terminate an execution unit on timeout or cancellation

It is an execution boundary for responsiveness and control, not a magical prison for JavaScript.

JavaScript has watched enough escape-room videos to know better.


πŸ‘€ Visualization: Variables Alone Are Not Enough

The Runtime returns an ExecutionState and snapshots for each step.

The next problem is deciding which View should display that trace.

DSA View View does not ask AI,

"Hey, does this look like Binary Search?"

on every run.

Instead, visualization candidates are detected through logic based on:

  • Variable names
  • Variable types and data structures
  • Whether arrays or matrices actually changed
  • Pointers such as left, right, and mid
  • The call stack
  • Function-entry and return history
  • Heap metadata
  • Changes across multiple steps

For example, displaying a Sort Graph whenever a number[] exists would classify algorithms that only read an input array as sorting algorithms.

So the detection logic checks whether the numeric array actually changes between steps.

It also looks for context such as sort, swap, partition, and pivot in descriptions or the call stack.

Some representative mappings are πŸ‘‡

Runtime state Displayed View
A changing numeric array Sort Graph
An array used as a stack Stack View
Search using left / right / mid Index View
Sliding Window pointers Sliding Window View
A two-dimensional array Matrix View
DP arrays or rolling state DP View
TreeNode Tree Graph
ListNode List Graph
Recursive function entry and return Recursion Tree
Logic using Map Map View
Logic using both MinHeap and MaxHeap Heap View
Word Ladder-style BFS Word Ladder View
Container / rain-water / histogram pointers Area View
Calculator character positions and sign stack Expression View

When multiple candidate Views are found, DSA View View assigns priorities and selects a primary Visualization.

For example, if heap state is available, Heap View takes priority over an ordinary array View.

Visualization Is Built from ExecutionState

Each Visualizer knows nothing about the user’s source code.

It extracts what it needs from the current ExecutionState and converts that data into View-specific state.

<BinarySearchVisualizer
  data={data}
  indexState={{
    left,
    right,
    mid,
  }}
/>
Enter fullscreen mode Exit fullscreen mode

When currentStep changes, the same Visualizer receives a new state.

CSS transitions and Framer Motion animate the difference, making pointers and nodes appear to move.

This creates a clean separation of responsibilities:

  • Runtime creates a correct trace while preserving the original behavior
  • Detection decides which View best represents the trace
  • Visualization focuses on how to display that state

Adding a new visualization generally follows this path:

  1. Detect the target execution state
  2. Convert it into View-specific state
  3. Render it with a React component
  4. Add it to the primary-visualization selection
  5. Add the smallest reproducible code sample as a test

πŸ§ͺ Tests: Reducing the Number of "It Happened to Work" Cases

Users can write code freely, which means they can also write it in ways I never expected.

AST transformations are especially dangerous because fixing one syntax pattern can quietly break another.

So when a real issue appears, I keep the smallest version of that code as a regression test.

Examples include:

  • Arguments and local variables for each recursive call
  • Postfix i++
  • XOR assignment
  • Nested array mutations
  • Class constructors and super()
  • TreeNode / ListNode inputs
  • Heap operations
  • Binary Search and Sliding Window pointers
  • Word Ladder BFS state
  • Worker timeouts and step limits

Trusting human memory to say,

"Surely this syntax still works."

is not a particularly strong testing strategy.

Memory is surprisingly optimistic.

And it would be a little embarrassing to build a tool for avoiding confusion in DSA, only to get lost inside the tool’s own behavior.


🎯 Summary

DSA View View is not simply turning an array into a bar chart.

At a high level, it works like this:

  1. Write TypeScript in Monaco
  2. Analyze the source code and signature with Babel
  3. Inject recordStep into the AST
  4. Execute the code inside a Web Worker
  5. Record variable snapshots and the call stack
  6. Replay the steps with React
  7. Display a View matching the data structure

The most important step was converting the user’s code into an array of ExecutionSteps.

Once execution becomes a sequence of steps, the same ExecutionState can power:

  • Pause
  • Forward and backward navigation
  • Timeline jumps
  • Navigation to the previous variable change
  • Reconstruction of recursion trees
  • Data-structure visualizations

The key idea is "Do not try to reverse JavaScript itself. First, transform execution into a recordable domain model".

Then,

"Runtime focuses on producing a correct trace, while Visualization focuses on presenting that trace."

Keeping Editor, Runtime, and Visualization independent from one another has been essential to making the tool extensible. Stable models such as ExecutionStep and ExecutionState connect these layers without coupling them directly.

There are still unsupported algorithms and syntax patterns, so I would love to hear from you if:

  • An implementation does not run
  • You want another data structure visualized
  • You think a trace would be clearer in a different View

Issues and PRs are always welcome πŸ‘‡

https://github.com/nyaomaru/dsa-view-view

Now let’s run our code and watch what it is doing inside together. πŸ‘€πŸ‘€

And if you enjoy the project, I would be very happy if you left a GitHub ⭐!

Top comments (18)

Collapse
 
publiflow profile image
PubliFlow

Rewinding execution state in JavaScript is notoriously tricky because of how reference types mutate under the hood, so I am curious if you are using structural sharing or deep cloning at each step to keep memory usage in check. Building visual debuggers or step-through tools always makes me appreciate the underlying engine mechanics even more. I actually ran into a similar state-tracking challenge when building our Next.js and Supabase SaaS boilerplate, PubliFlow, where we needed to visualize complex data flow changes for the user without tanking performance. How are you handling the serialization of circular references if they ever get caught in your execution snapshot?

Collapse
 
nyaomaru profile image
nyaomaru

Thank you for great question. πŸ‘

We use a hybrid approach that combines deep cloning for each step with lazy shallow reconstruction instead of full structural sharing.

Conceptually, it looks like this πŸ‘‡

deltas[step] = deepClone(visibleVariables)

snapshots[step] ??= {
  ...(snapshots[step - 1] ?? {}),
  ...deltas[step],
}

// Rewinding only changes the selected timeline step
currentStep--
Enter fullscreen mode Exit fullscreen mode

deepClone uses structuredClone when possible. This isolates the captured values for each step. Rewinding simply moves through the recorded timeline. It does not reverse mutations or run the code again.

The tradeoff is that a large object visible across many steps may still be cloned repeatedly, so traces are currently capped at 3,000 steps.

Runtime snapshots are not serialized as JSON. structuredClone preserves cycles and shared references. A WeakMap based fallback handles unsupported cases. The UI formatter is intentionally lossy and displays repeated references as "[Circular]".

PubliFlow sounds like it faced a very similar balance between snapshot fidelity and keeping the visualization responsive. 😼

Collapse
 
publiflow profile image
PubliFlow

That hybrid approach is a clever compromise. Relying on deep cloning for visible variables while lazily reconstructing the rest via spread operators likely keeps the memory overhead manageable compared to full structural sharing. I am curious how you handle circular references or non-serializable objects like DOM nodes when performing that initial deep clone.

Thread Thread
 
nyaomaru profile image
nyaomaru

Yeah, that’s pretty much it! Full structural sharing would probably be even more memory efficient, but this hybrid keeps the implementation simpler while avoiding eager reconstruction of every complete snapshot.

Circular references are handled by structuredClone. If that cannot clone a value, our fallback uses a WeakMap to preserve cycles and shared references.

DOM nodes are a little different. Execution runs inside a Web Worker, so they cannot enter the execution context in the first place. We mainly support algorithm focused data such as objects, arrays, maps, sets, dates, and regular expressions. If values such as functions or symbols need to cross the worker boundary, we replace them with readable labels. 😸

Thread Thread
 
publiflow profile image
PubliFlow

Using structuredClone with a WeakMap fallback is a pragmatic way to handle circular references without reinventing the wheel. I am curious about how you handle DOM nodes since they are inherently non-serializable and tied to the live document state. Do you serialize their attributes and tree structure, or just maintain lightweight references to the actual DOM elements during the rewind process?

Thread Thread
 
nyaomaru profile image
nyaomaru

Neither, actually.
We do not currently have a DOM specific snapshot path, so we neither serialize the tree nor retain live element references.

If we add DOM support later, I would use a small serializable projection of the relevant attributes and structure.
Live references would keep mutating and undermine reliable rewinding. 😸

Thread Thread
 
publiflow profile image
PubliFlow

Avoiding live references is definitely the right call since mutating DOM nodes would completely break the deterministic nature of a rewind feature. Building a serializable projection of just the relevant attributes is a smart way to keep snapshots lightweight and reliable. When you do eventually add DOM support, have you thought about how you will handle shadow DOM boundaries or cross-origin iframes?

Thread Thread
 
nyaomaru profile image
nyaomaru

Thank you for your suggestion & question.

Honestly, DOM support is not currently planned. DSA View View is focused on visualizing algorithm data, and keeping execution inside a Web Worker is an intentional boundary.

Because of that, shadow DOM and cross origin iframe handling would remain outside the project’s scope. 😸

Thread Thread
 
publiflow profile image
PubliFlow

Isolating the execution inside a Web Worker definitely keeps the state management clean, especially when you want to avoid the nightmare of tracking DOM mutations for rewinding. Since the scope is strictly algorithm data, have you explored exporting those state snapshots to a JSON format so users can share or debug complex traces offline?

Thread Thread
 
nyaomaru profile image
nyaomaru

That’s a great idea! I have not explored exporting full execution snapshots as JSON yet.

The current share feature encodes the source code, inputs, and selected step into a URL so others can recreate the same setup. It does not include the recorded execution history itself.

A JSON export could be useful for offline debugging or preserving a trace without running the code again, so it is definitely something worth exploring. 😸

Thread Thread
 
publiflow profile image
PubliFlow

Encoding the setup into a URL is perfect for quick collaboration, but capturing the actual execution trace as JSON would definitely level up the debugging experience. You could even compress that JSON history using something like LZString to keep the payload lightweight if you ever decide to bundle it into the shared URL later. Have you considered adding a timeline scrubber that lets users step through that exported JSON trace independently of the original code?

Thread Thread
 
nyaomaru profile image
nyaomaru

Interesting idea! What kind of use case did you have in mind for replaying an exported trace? 😸

Thread Thread
 
publiflow profile image
PubliFlow

I mostly see it as a game-changer for asynchronous bug reporting, allowing developers to share exact execution states instead of writing lengthy reproduction scripts. It would also be incredibly useful for performance regression testing to verify that recent optimizations didn't alter the underlying logic. Have you considered how you might handle state serialization for external objects like DOM nodes when exporting these traces?

Thread Thread
 
nyaomaru profile image
nyaomaru

That makes sense. Sharing an exact trace could be really useful for debugging algorithm logic without requiring a lengthy reproduction script.

DSA View View is primarily a learning and visualization tool rather than a general purpose runtime debugger, so I’d want to validate how much demand there is before expanding in that direction.

DOM nodes aren’t currently part of the execution state because user code runs in a Web Worker without DOM access. If external objects were supported in the future, I’d likely export an explicit serializable representation rather than the live objects themselves.

Thanks, this gives me a much clearer use case to consider! 😸

Collapse
 
ddebajyati profile image
Debajyati Dey

very cool!🐱

Collapse
 
nyaomaru profile image
nyaomaru

Thx!! 😸

Collapse
 
mudassirworks profile image
Mudassir Khan

the AST transform approach is the right call here. the alternative is running the original code and somehow snapshotting state externally, which falls apart the moment closures or mutation are in play. by injecting recordStep at transform time, you own the snapshot shape β€” that's the part where most replay systems break.

the Web Worker isolation is what i didn't see coming but immediately makes sense. without it, an infinite loop hangs the whole UI. we hit this running untrusted LLM generated snippets in a browser runner; Worker plus message timeout kill switch is the only safe pattern.

curious whether the AST transformation handles generators and async/await, or whether those are explicitly out of scope for DSA use cases?

Collapse
 
nyaomaru profile image
nyaomaru

Thanks for the thoughtful comment 😸

That’s exactly why I went with the AST transform approach. The injected recordStep calls capture state at meaningful execution boundaries.
The UI navigates that recorded trace, so stepping backward, forward, or jumping between steps does not require re-running the code or trying to undo JavaScript mutations πŸ‘

Web Workers aren’t something I reach for in most UI work, but they’re a great fit when execution needs to be separated from rendering. An infinite loop cannot freeze the main UI thread, and the host can terminate the Worker when the timeout expires.

async/await is supported now!
The runtime waits for Promise-returning entry functions and records await suspension, resumption, and rejection.
It also tracks concurrent in-process branches such as Promise.all independently, so their call frames do not get mixed together.
Full event-loop visualization, external side-effect replay, and network request visualization are still outside the current scope.

Generators, however, are not yet supported as a first-class traced execution model.
Supporting them properly means tracking frame state across yield, next, throw, and return.
It’s a great suggestion, and I plan to support them 😼