DEV Community

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

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

nyaomaru on July 29, 2026

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

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 😼