Nov 23, 2025
5 min read

RequestAnimationFrame to the Rescue: Killing Slider Lag

Moving a slider was triggering 7000+ array iterations and nine console.logs per frame - no wonder it lagged.

The time slider on our vehicle trial visualization was sluggish. Not “slightly slow” - properly laggy. You’d drag the slider and the visualization would stutter behind, catching up in jerky frames.

This was unacceptable. The whole point of the slider was to let you scrub through a trial and see what the neural network was doing at different timesteps. Laggy scrubbing defeats the purpose.

So I profiled it. What I found was worse than expected.

The Symptom

Every time you moved the slider, even by one pixel, the entire visualization re-rendered. The CTRNN network state updated. The vehicle position updated. The output graphs updated. Everything updated.

That’s expected - the slider controls which timestep you’re viewing, so changes should trigger updates.

What wasn’t expected: each update was recalculating output bounds by iterating through 1000+ timestep arrays. And it was logging to the console. Nine times. Per frame.

Moving the slider continuously meant 60 frames per second. 60 frames times 7000 iterations times 9 console.logs equals… a very bad time.

The Investigation

I added performance markers around the suspect code:

console.time('updateBounds')
const bounds = getOutputBounds(trial)  // 1000-timestep array
console.timeEnd('updateBounds')

Result: 12ms per call. On a 60fps frame budget of 16.6ms, that left 4.6ms for everything else. No wonder it lagged.

The getOutputBounds() function was doing this:

export function getOutputBounds(trial) {
    let min = Infinity
    let max = -Infinity

    for (const timestep of trial) {
        for (const output of timestep.outputs) {
            min = Math.min(min, output)
            max = Math.max(max, output)
        }
    }

    return { min, max }
}

For a 1000-timestep trial with 7 outputs, that’s 7000 array accesses and 14000 Math operations. Every time the slider moved.

And it was being called from three different components: the output graph, the neuron visualization, and the trial wrapper. Each recalculating the same bounds from the same data.

The Quick Win: Kill the Logs

First fix was obvious - delete all the console.log statements. They were debug logs left over from development. In production, they just burned CPU.

Deleted 9 console.log calls. Frame time dropped from 16ms to 13ms. Better, but still laggy.

The Real Fix: Cache the Bounds

The bounds don’t change. A trial’s output bounds are fixed when the trial is generated. Recalculating them on every slider movement is wasteful.

Solution: calculate once, cache the result.

I used a WeakMap for caching:

const boundsCache = new WeakMap()

export function getOutputBounds(trial) {
    // Check cache first
    if (boundsCache.has(trial)) {
        return boundsCache.get(trial)
    }

    // Calculate bounds (same logic as before)
    let min = Infinity
    let max = -Infinity

    for (const timestep of trial) {
        for (const output of timestep.outputs) {
            min = Math.min(min, output)
            max = Math.max(max, output)
        }
    }

    const bounds = { min, max }

    // Cache the result
    boundsCache.set(trial, bounds)

    return bounds
}

Now the first call calculates bounds (12ms), but every subsequent call is a WeakMap lookup (< 0.01ms). The slider can move freely and bounds retrieval is effectively free.

WeakMap is perfect here because it doesn’t prevent garbage collection. When a trial object is no longer referenced, the cache entry disappears automatically. No memory leaks.

The Result

Frame time dropped from 16ms to ~3ms. Slider felt instant. You could scrub through a 1000-timestep trial smoothly.

The fix was embarrassingly simple. Cache expensive calculations. Don’t recalculate static data. Use appropriate data structures.

But here’s the thing - without profiling, I wouldn’t have known where the time was going. It could’ve been Svelte’s reactivity system. It could’ve been the rendering pipeline. It could’ve been the physics simulation.

It was console.logs and unnecessary array iterations.

The Broader Pattern

This pattern shows up everywhere in UI performance:

  1. Expensive calculation
  2. Called on every frame/interaction
  3. Result doesn’t actually change
  4. No caching

The fix is always the same: identify what’s static, calculate it once, cache it.

The tricky part is identifying what is static. In our case, trial data was immutable once generated, so bounds were static. But if trials were mutable, caching would be wrong - you’d show stale bounds.

The key is understanding your data’s lifecycle. If it’s immutable or changes rarely, cache aggressively. If it’s constantly changing, caching won’t help.

Our trial data was immutable. Caching was free performance.

The Lesson

Performance problems come in two flavors: algorithmic and incidental.

Algorithmic problems require rethinking the approach. If you’re using O(n²) when you need O(n log n), no amount of optimization will save you.

Incidental problems are accidents. Console.logs left in production. Unnecessary recalculations. Missing caches. They’re easy to fix once you find them.

The challenge is finding them. You can’t optimize what you don’t measure.

Profile first. Optimize second. Don’t guess.

And for the love of all that is good, remove your debug logs before shipping.

References