Separating the Rendering Loop to Prevent Frame Drops When Using React and WebGL Together
TuBrief 편집팀
2026년 8월 7일
0
Computing/Software원본 영상을 바탕으로 AI의 도움을 받아 작성했습니다. 원본 영상이 기준입니다.
커뮤니티의 다른 글
댓글 (0)
Log in to leave a comment
아직 작성된 글이 없습니다
원본 영상을 바탕으로 AI의 도움을 받아 작성했습니다. 원본 영상이 기준입니다.
Log in to leave a comment
아직 작성된 글이 없습니다
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:
next.config.mjs encourages the omission of unnecessary modules.{ ssr: false } to next/dynamic blocks runtime errors at the time of server rendering.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.
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:
current property of useRef.requestAnimationFrame loop with the useAnimationFrame hook.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:
min-h-[400px] on the wrapper box to prevent screen aspect ratios from breaking when switching to fallback UI (plain HTML/SVG charts, etc.).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:
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.