Development notes
Why the background flow lines skipped frames: from a fixed FPS cap to drawing on every rAF
Using the actual Canvas flow-line render loop and a browser observation, this note explains why a fixed 30 FPS cap discards drawing opportunities, and how updating on every requestAnimationFrame can preserve animation speed.
- Canvas
- requestAnimationFrame
- Animation
- Performance
The implementation and measurements below describe the version recorded on October 2, 2026. References to the current loop mean the loop at that stage. The 60 callbacks, 60 draws, and approximately 60.1 FPS are the original observation, not a new measurement made while translating this note.
A background animation may look like decoration behind the page, but once it covers the viewport, it keeps working alongside the browser’s rendering cycle. The flow lines were skipping frames not because Canvas was unsuitable for animation, but because the render loop was discarding frames the browser had already offered it.
The earlier approach checked for an interval of about 33 milliseconds inside the requestAnimationFrame callback. That capped the animation at roughly 30 FPS: even when the browser could provide more frames, the loop would not draw on every callback. The revised approach removed that fixed threshold, updating and drawing on every rAF in normal mode and using elapsed time to control movement. The main judgment is this: if the goal is to follow actual display updates, do not discard a fixed proportion of frames inside the callback.
A requestAnimationFrame callback is a drawing opportunity, not something to discard casually
The browser calls requestAnimationFrame at the next suitable opportunity to update the display. It does not guarantee exactly 60 callbacks every second, nor does every screen refresh at the same rate. Workload, tab state, and device differences can all change the timing.
The old logic was roughly as follows. The original diagram is retained below: on receiving an rAF callback, skip drawing and wait for the next callback if less than about 33ms has passed since the last draw; otherwise, clear the Canvas, draw the flow lines, and update the particles.
收到 rAF callback
├─ 距離上次繪製不到約 33ms → 不畫,等下一次 callback
└─ 否則才清空 Canvas、畫流線、更新粒子
This check did cap drawing at roughly 30 FPS, but it also discarded display updates available during those callbacks. When the display and browser update at a higher rate, the animation may appear to jump from frame to frame rather than move with the display’s refresh rhythm.
The problem is not that the number 30 is always wrong. It is that a fixed output ceiling was hardcoded into a browsing environment whose timing is not necessarily fixed. If frame limiting is genuinely needed, it should serve a clear power, workload, or visual goal, with measurements supporting the trade-off. A large number of callbacks alone is not a reason to assume discarding some of them is safe.
What the revised render loop does
In the normal animation mode documented here, each call to render:
- Increments the callback and draw counts.
- If the page is not paused, calculates elapsed time since the previous draw.
- Clears the transparent Canvas.
- Draws the HUD, flow lines, and data-packet particles.
- Accumulates animation time and FPS counts.
- Requests the next
requestAnimationFrame.
The code no longer has a fixed FPS threshold that returns immediately when fewer than 33 milliseconds have passed since the previous draw. In normal mode, one callback therefore corresponds to one draw. Hidden pages and the user’s reduced-motion setting follow separate state handling; the loop does not draw unconditionally in every situation.
The data-callbacks and data-draws attributes make the actual counts readable during checks. They are not a performance-ranking dashboard. They answer how many callbacks and draws this page execution performed, not the average FPS or power consumption across all devices.
Speed needs to follow time, not just frame count
After removing the fixed 30 FPS threshold, advancing particles by the same fixed amount on every callback would tie their speed to the callback count. More callbacks would make them move faster; fewer would make them move slower.
The flow-line animation time in this implementation uses:
const elapsedSeconds = Math.min(now - previousRenderTime, 50) / 1000
previousRenderTime = now
time += elapsedSeconds
Particle progress is also scaled by elapsed time, with a cap on the scale applied in a single update:
const frameScale = Math.min(elapsedSeconds * 30, 2)
p.progress += p.speed * frameScale
This does not make the animation identical on every device. It avoids refresh-rate differences caused by a fixed advance per frame, while preventing particles from jumping too far after a tab resumes or the main thread briefly stalls. The cap on elapsedSeconds also makes this a visual compromise, not a physical simulation.
Removing the 33-millisecond check without handling time differences would exchange one problem for another: more display updates, but an animation speed that could vary with the device.
What we actually measured
In normal animation mode, the script documented here writes callback and draw counts to the background container’s dataset. During the original one-second observation in a local browser, with the page visible and reduced-motion disabled, the readings were a callback increase of 60, a draw increase of 60, and page-reported FPS of approximately 60.1. The original output labels are retained:
callbacks 增量:60
draws 增量:60
頁面回報 FPS:約 60.1
This supports a narrow conclusion: in that execution, normal mode did not use a fixed 30 FPS threshold to discard half the callbacks, and callback and draw counts increased by the same amount.
It does not support these broader conclusions:
- Every screen will achieve 60 FPS.
- Every phone can smoothly render 16 flow lines and particle trails.
- The Canvas’s CPU, GPU, or battery cost is already low enough.
- Browser behavior after production deployment is identical to local behavior.
The perceived animation still depends on viewport size, device refresh rate, other page work, and user settings. Measurements should retain their environment and observation scope rather than turning a single browser reading into a universal performance guarantee.
Drawing on every rAF does not mean running continuously in every state
This fix addressed drawing frequency only in the normal, visible state. It should not be read as a requirement to keep computing all the time. The background script retained several other boundaries:
- When
document.hiddenis true, cancel the next rAF and schedule again when the page becomes visible. - When
prefers-reduced-motion: reduceapplies, stop the animation loop and redraw only static flow lines. - On
resize, adjust the Canvas and flow-line dimensions, redrawing the reduced-motion state when necessary. - On
pagehide, clean up listeners, animation, and any frame that has not yet been canceled. - On BFCache restoration, reinitialize the background controls so the returning page neither stops completely nor binds listeners twice.
These are lifecycle and accessibility boundaries, separate from whether normal mode draws on every rAF. Fixing skipped frames is no reason to remove hidden-tab handling, reduced-motion support, or cleanup when leaving the page.
If lower-frequency updates are needed, explain why first
Lower-frequency updates can have a legitimate use. If measurements show that drawing the background affects the main content on a particular device, options include reducing the number of lines or particles, shortening trails, or disabling the effect in certain states. A strategy can also target explicit device conditions or page states.
A fixed 30 FPS cap is only one option, and it directly changes the animation’s update opportunities. Before adopting it, we should at least know:
- Is the problem CPU load, GPU load, battery use, or main-content latency?
- Which devices or states are affected?
- Does reducing the update frequency actually improve the visual result, or merely replace stutter with a low update rate?
- Do reduced-motion, hidden tab handling, and cleanup on leaving the page still work?
Without that information, letting the render loop reflect the frames the browser offers and then measuring the actual cost makes the problem easier to locate than hardcoding an apparently conservative ceiling first.
What this fix can and cannot demonstrate
The code changes and local measurement support these conclusions:
- The earlier version had a fixed drawing interval of about 33 milliseconds inside the rAF callback.
- The revised normal mode removed that threshold and performs a draw on every render.
- Animation time and particle progress use elapsed time rather than only fixed increments per frame.
- In this visible-page observation, callback and draw counts each increased by about 60, with matching counts.
They do not directly support conclusions about performance across devices, power consumption, long-term stability, or every browser. Whether the background animation is worth keeping also depends on main-content readability, the user’s reduced-motion setting, and actual visual acceptance.
When fixing animation, first inspect what the drawing loop is discarding, then decide whether to change frequency, workload, or lifecycle behavior. A fixed number may be easy to understand without describing the actual boundary of the problem.
Scope of this record
This note draws on the background Canvas script, the historical fixed-interval implementation, the callback/draw counters in the version documented here, and one local browser observation. The measurement represents only that visible-page execution. It makes no performance claim for all devices and does not include a complete set of hidden tab, reduced-motion, or long-running stress tests.