Separating the Rendering Loop to Prevent Frame Drops When Using React and WebGL Together
Integrating a WebGL canvas into a Next.js dashboard is trickier than it seems. When adding flashy charts or heavy graphic elements, the screen often stutters. If frames drop just by moving the mouse and your carefully crafted dashboard lags, it's bound to frustrate any developer.
This issue usually happens because React's state management approach conflicts with WebGL's rendering workflow. Unless you separate how these two engines work, the screen will keep stuttering no matter how good the libraries you use are.
The Problem of Graphic Libraries Entirely Included in the Main Bundle
Libraries like Three.js and Deck.gl are bulky because they contain mathematical calculation engines and shader compilers inside them. If you import the package at the top without thinking, even unused code gets mixed into the main bundle.
In a Next.js environment, you need to combine direct subpath imports and dynamic load configurations to lift this weight. You can also prevent window is not defined errors during server-side rendering.
`javascript
// next.config.mjs
import withBundleAnalyzer from '@next/bundle-analyzer';
const bundleAnalyzer = withBundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
});
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
experimental: {
optimizePackageImports: [
'@radix-ui/react-icons',
'lucide-react',
'three',
'deck.gl'
],
},
webpack: (config, { isServer }) => {
if (!isServer) {
config.resolve.fallback = {
...config.resolve.fallback,
fs: false,
path: false,
};
}
return config;
},
};
export default bundleAnalyzer(nextConfig);
`
Through this configuration, you receive graphic resources separately when needed instead of downloading them all at once during the initial page load. Checking with a bundle analyzer, you can see that the canvas-related code previously bound to the main JS file has broken off into an independent chunk.
In the client component wrapper, import it with SSR turned off.
`typescript
// components/dashboard/CanvasDashboardWrapper.tsx
'use client';
import dynamic from 'next/dynamic';
import { Skeleton } from '@/components/ui/skeleton';
const DynamicCanvasRenderer = dynamic(
() => import('./CanvasRenderer').then((mod) => mod.CanvasRenderer),
{
ssr: false,
loading: () => (
),
}
);
export function CanvasDashboardWrapper() {
return (
);
}
`
Configuring it this way immediately yields three benefits:
- Registering packages in
next.config.mjs encourages the omission of unnecessary modules.
- Passing
{ ssr: false } to next/dynamic blocks runtime errors at the time of server rendering.
- Displaying a fixed-height Skeleton catches layout jumps before data arrives.
If you use Tailwind CSS, you should also check pointer events. Apply pointer-events-none to the entire canvas wrapper so that events pass through to the DOM behind it, and reapply pointer-events-auto only to the object layers that actually require manipulation.
Clearing useState Out of the Canvas Loop
React redraws the virtual DOM whenever states (useState, useContext) change. On the other hand, WebGL redraws the screen 60 times per second riding on requestAnimationFrame.
What happens if you call a React state-changing function inside a loop running 60 times per second? React's reconciliation operation runs every single frame, freezing the main thread. This is the real reason the screen lags.
Changing values should be stored in useRef, and the work of drawing the screen should be left to a standalone requestAnimationFrame loop.
`typescript
// hooks/useAnimationFrame.ts
import { useEffect, useRef } from 'react';
type AnimationCallback = (deltaTime: number, timestamp: number) => void;
export const useAnimationFrame = (callback: AnimationCallback, isPaused: boolean = false) => {
const requestRef = useRef<number | null>(null);
const previousTimeRef = useRef<number | null>(null);
const callbackRef = useRef(callback);
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
useEffect(() => {
if (isPaused) {
if (requestRef.current !== null) {
cancelAnimationFrame(requestRef.current);
}
return;
}
const animate = (timestamp: number) => {
if (previousTimeRef.current !== null) {
const deltaTime = timestamp - previousTimeRef.current;
callbackRef.current(deltaTime, timestamp);
}
previousTimeRef.current = timestamp;
requestRef.current = requestAnimationFrame(animate);
};
requestRef.current = requestAnimationFrame(animate);
return () => {
if (requestRef.current !== null) {
cancelAnimationFrame(requestRef.current);
}
};
}, [isPaused]);
};
`
Events that occur frequently, such as mouse movement or dragging, are the same. Updating React state in event handlers causes garbage collection to cluster in a short time, leading to lag.
Change the structure:
- Frequently changing data, like mouse position or rotation values, is overwritten into the
current property of useRef.
- Create a unique
requestAnimationFrame loop with the useAnimationFrame hook.
- The rendering function simply reads this Ref value and draws it onto the canvas. Since it doesn't touch React state, re-rendering itself does not occur.
Fallback Handling for Unsupported Browsers
Similar to the HTML in Canvas specification being discussed in WICG, there are attempts to draw the DOM directly onto a canvas bitmap using methods like drawElementImage. While it works well in modern Chrome flag environments, relying on this blindly in production will cause screens to break entirely in older browsers or some mobile devices.
Read feature support first, and wrap it in an error boundary to prepare for exceptional situations.
`typescript
// components/canvas/CanvasErrorBoundary.tsx
'use client';
import React, { Component, ErrorInfo, ReactNode } from 'react';
import { detectCanvasCapabilities } from '@/utils/canvasFeatureDetection';
interface Props {
children: ReactNode;
fallbackUI: ReactNode;
}
interface State {
hasError: boolean;
isSupported: boolean;
}
export class CanvasErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false,
isSupported: true,
};
public componentDidMount() {
const capabilities = detectCanvasCapabilities();
if (!capabilities.webgl2) {
this.setState({ isSupported: false });
}
}
public static getDerivedStateFromError(_: Error): Partial {
return { hasError: true };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Canvas UI Rendering Engine Crashed:', error, errorInfo);
}
public render() {
if (this.state.hasError || !this.state.isSupported) {
return (
);
}
return (
<div className="relative w-full h-full min-h-[400px] overflow-hidden">
{this.props.children}
</div>
);
}
}
`
Here is how to set up a safety net:
- Check WebGL2 support status through canvas context verification logic.
- Catch runtime errors occurring during rendering with a React class-based error boundary.
- Fix a minimum height like
min-h-[400px] on the wrapper box to prevent screen aspect ratios from breaking when switching to fallback UI (plain HTML/SVG charts, etc.).
GPU Memory Is Not Cleared by the JavaScript Garbage Collector
The V8 engine cleans up objects inside the JavaScript heap quite well. However, it doesn't know about data placed in GPU memory, such as WebGL buffers, textures, and shader programs.
If you don't explicitly clear these resources when navigating pages or switching dashboard tabs, GPU memory builds up until a browser tab crashes along with a Context lost error.
`typescript
// hooks/useWebGLCleanUp.ts
import { useEffect, useRef } from 'react';
export const useWebGLCleanUp = () => {
const glRef = useRef<WebGL2RenderingContext | null>(null);
const resourcesRef = useRef<{
buffers: WebGLBuffer[];
textures: WebGLTexture[];
programs: WebGLProgram[];
}>({
buffers: [],
textures: [],
programs: [],
});
const registerBuffer = (buffer: WebGLBuffer) => resourcesRef.current.buffers.push(buffer);
const registerTexture = (texture: WebGLTexture) => resourcesRef.current.textures.push(texture);
const registerProgram = (program: WebGLProgram) => resourcesRef.current.programs.push(program);
useEffect(() => {
return () => {
const gl = glRef.current;
if (!gl) return;
resourcesRef.current.buffers.forEach((buffer) => gl.deleteBuffer(buffer));
resourcesRef.current.textures.forEach((texture) => gl.deleteTexture(texture));
resourcesRef.current.programs.forEach((program) => {
const shaders = gl.getAttachedShaders(program);
if (shaders) {
shaders.forEach((shader) => {
gl.detachShader(program, shader);
gl.deleteShader(shader);
});
}
gl.deleteProgram(program);
});
const loseContextExt = gl.getExtension('WEBGL_lose_context');
if (loseContextExt) {
loseContextExt.loseContext();
}
resourcesRef.current = { buffers: [], textures: [], programs: [] };
glRef.current = null;
};
}, []);
return { glRef, registerBuffer, registerTexture, registerProgram };
};
`
Here is the sequence to check directly with Chrome DevTools whether memory is properly released:
- Open the dashboard component and take the first heap snapshot (S1) in the Memory tab.
- Repeat component mounting and unmounting (such as navigating to other tabs) 10 or more times.
- Click manual garbage collection (the trash can icon) and take the second heap snapshot (S2).
- Set S2 comparison target to S1 and verify that the number of
Detached HTMLCanvasElement or WebGLBuffer instances drops down to 0 without remaining.
When using React and WebGL together, the key is to ground them so that neither library harms the areas the other does best. Splitting bundles, clearing React state out of the rendering loop, and explicitly freeing GPU memory when components disappear. Taking care of just these three things will allow you to build a cleanly running dashboard without frame drops.