the render loop — one boolean, never stops

2026-05-03

most rendering engines i've read start and stop their animation loop on demand: scroll begins, schedule a frame; scroll ends, cancel. cytoscape doesn't. it starts a requestAnimationFrame loop once when the renderer initialises, and that loop runs every frame until the renderer is destroyed. whether to actually draw is decided by a single boolean.

this is post 1 of the series. let's read the loop.

the loop

the whole render loop fits in one function. it's defined inside BRp.startRenderLoop:

var renderFn = function( requestTime ){
  if( r.destroyed ){ return; }

  if( cy.batching() ){
    // mid-batch, none of these should run
  } else if( r.requestedFrame && !r.skipFrame ){
    beforeRenderCallbacks( r, true, requestTime );

    var startTime = util.performanceNow();
    r.render( r.renderOptions );
    var endTime = r.lastDrawTime = util.performanceNow();

    // timing bookkeeping…
    r.requestedFrame = false;
  } else {
    beforeRenderCallbacks( r, false, requestTime );
  }

  r.skipFrame = false;
  util.requestAnimationFrame( renderFn );
};

util.requestAnimationFrame( renderFn );

three branches, in order:

  1. mid-batch. if cy.batching() is true, do nothing this frame. batching is the mechanism by which a layout run (or any bulk update) tells the renderer "stop reacting until i'm done." we'll meet it again in part 7.
  2. drawing frame. r.requestedFrame is the dirty bit. if it's set and we're not skipping this frame for motion-blur reasons, run pre-frame callbacks with willDraw=true, draw, then clear the bit.
  3. idle frame. requestedFrame is false. nothing to draw. but pre-frame callbacks still run, this time with willDraw=false.

then, unconditionally, util.requestAnimationFrame(renderFn). the loop schedules itself.

what schedules a redraw? anywhere in the codebase that calls r.redraw():

BRp.redraw = function( options ){
  options = options || util.staticEmptyObject();
  var r = this;
  // …
  r.requestedFrame = true;
  r.renderOptions = options;
};

redraw() does not draw. it just flips the boolean. the next time the rAF loop ticks, it'll see requestedFrame === true and actually paint. multiple redraw() calls in the same frame are coalesced for free — the boolean can only be true, no matter how many times you set it.

what runs before each frame

the interesting thing about the idle branch is that it still calls beforeRenderCallbacks. so what goes in there?

beforeRender is the registration api:

BRp.beforeRender = function( fn, priority ){
  if( this.destroyed ){ return; }

  if( priority == null ){
    util.error('Priority is not optional for beforeRender');
  }

  var cbs = this.beforeRenderCallbacks;
  cbs.push({ fn: fn, priority: priority });

  // higher priority callbacks executed first
  cbs.sort(function( a, b ){ return b.priority - a.priority; });
};

every callback registers a priority. the array is sorted descending after every push. higher-priority callbacks run first. five of them are wired up across the renderer, in this order:

prioritynamewhat it does
400animationsadvance the animation engine — interpolate positions, colors, sizes
300eleCalcsflush dirty style, recalculate edge projections and label positions
200eleTxrDeqdequeue refinements for the per-element texture cache
150lyrTxrDeqdequeue refinements for the layered texture cache
100lyrTxrSkipset the skipping flag if invalidations are arriving too fast

each callback receives (willDraw, requestTime). they branch on willDraw. animations advance regardless — if a tween is in flight, every frame matters. but the texture-cache dequeue callbacks behave differently in the two branches:

  • drawing frame: dequeue a tiny budget. don't steal time from the actual paint.
  • idle frame: dequeue aggressively. up to 90% of a 60fps frame. the user can't see what's happening; spend the time refining cached textures.

this is the trick that makes cytoscape's loop earn its keep on idle frames. the rAF loop is free of cost only if the cpu does nothing — but the cpu is already running at 60fps, so we may as well use it for upgrades that improve the next paint. parts 3 and 4 dive into what those upgrades actually are; for now, the takeaway is that idle frames are not wasted frames.

why it never stops

starting and stopping a rAF loop is harder than it looks. you need to handle:

  • redraws scheduled mid-stop. did the frame already get cancelled, or not? race conditions.
  • multiple subsystems independently asking for redraws. who gets to cancel? reference counting?
  • subsystems that need between-frame hooks even when no draw is happening (animation engine, dequeue work). a stopped loop kills them.

cytoscape sidesteps all of this by never stopping. the cost is one boolean check and one requestAnimationFrame call per frame — both nanoseconds. in exchange:

  • redraw() is just requestedFrame = true. no scheduling logic, no cancellation logic.
  • between-frame work always has a place to run. animations advance, dequeue work proceeds, regardless of whether the user is interacting.
  • the dirty-bit collapses redundant requests. fifty redraw() calls in one frame still produce one paint.
  • batching short-circuits via cy.batching() rather than via "is the loop running?". one branch in renderFn, instead of a separate "stop loop" code path.

the loop never stops, but the work inside it scales with how much there is to do. an idle graph spends most frames in the third branch, doing some background dequeue work, then sleeping until the next vsync. an animating graph spends most frames in the second branch, drawing. switching between the two states is a boolean flip.

                    every rAF tick
                          │
                          ▼
                    cy.batching()?
                    /            \
                  yes             no
                   │               │
                   ▼               ▼
              do nothing      requestedFrame?
                              /            \
                            yes             no
                             │               │
                             ▼               ▼
                  pre-frame (willDraw=true)   pre-frame (willDraw=false)
                  r.render()                  ↳ dequeue, animate
                  requestedFrame = false

three branches, one boolean (plus the batching flag) deciding which one we're in this frame. the rAF loop itself doesn't change.

next

next post — part 2: three canvas layers (link will resolve when part 2 ships). the renderer doesn't actually paint into a single canvas. it stacks three, each with its own dirty bit, so dragging only repaints what's moving.