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:
three branches, in order:
- 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. - drawing frame.
r.requestedFrameis the dirty bit. if it's set and we're not skipping this frame for motion-blur reasons, run pre-frame callbacks withwillDraw=true, draw, then clear the bit. - idle frame.
requestedFrameis false. nothing to draw. but pre-frame callbacks still run, this time withwillDraw=false.
then, unconditionally, util.requestAnimationFrame(renderFn). the loop schedules itself.
what schedules a redraw? anywhere in the codebase that calls r.redraw():
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:
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:
| priority | name | what it does |
|---|---|---|
| 400 | animations | advance the animation engine — interpolate positions, colors, sizes |
| 300 | eleCalcs | flush dirty style, recalculate edge projections and label positions |
| 200 | eleTxrDeq | dequeue refinements for the per-element texture cache |
| 150 | lyrTxrDeq | dequeue refinements for the layered texture cache |
| 100 | lyrTxrSkip | set 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 justrequestedFrame = 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 inrenderFn, 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.