Your React app needs to render a complex chart with 100,000 data points. Or animate a 3D visualization. Or process real-time video streams. You implement it on the main thread, and suddenly your entire UI freezes. Every interaction becomes sluggish. The browser’s performance monitor shows one long, red bar blocking everything.
Here’s the problem: canvas operations and WebGL rendering are computationally expensive, and when they run on the main thread, they block everything else. But there’s a solution that most React developers don’t know about: OffscreenCanvas. It lets you move all that heavy graphics work to a Web Worker, keeping your main thread free and your UI responsive.
Let’s explore how to build blazing-fast visualizations and graphics in React using OffscreenCanvas and WebGL, without sacrificing interactivity.
Understanding OffscreenCanvas
OffscreenCanvas enables canvas rendering in Web Workers:
interface OffscreenCanvasCapabilities {
rendering: 'Runs in Web Worker';
threading: 'Parallel to main thread';
contexts: ['2d', 'webgl', 'webgl2', 'webgpu'];
performance: 'No main thread blocking';
}
// Check for support
const isSupported = 'OffscreenCanvas' in window;
// Transfer canvas control to worker
const canvas = document.getElementById('canvas') as HTMLCanvasElement;
const offscreen = canvas.transferControlToOffscreen();
// Send to worker
worker.postMessage({ canvas: offscreen }, [offscreen]);Basic OffscreenCanvas Setup
Creating the Worker Infrastructure
// canvas.worker.ts
let canvas: OffscreenCanvas | null = null;
let ctx: OffscreenCanvasRenderingContext2D | null = null;
let animationId: number | null = null;
self.addEventListener('message', (event) => {
const { type, data } = event.data;
switch (type) {
case 'init':
initCanvas(data.canvas);
break;
case 'render':
renderFrame(data);
break;
case 'startAnimation':
startAnimation();
break;
case 'stopAnimation':
stopAnimation();
break;
case 'resize':
resizeCanvas(data.width, data.height);
break;
}
});
function initCanvas(offscreenCanvas: OffscreenCanvas) {
canvas = offscreenCanvas;
ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Could not get 2D context');
}
// Initial render
renderFrame({});
}
function renderFrame(data: any) {
if (!ctx || !canvas) return;
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Expensive rendering operation
for (let i = 0; i < 10000; i++) {
const x = Math.random() * canvas.width;
const y = Math.random() * canvas.height;
const radius = Math.random() * 5;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fillStyle = `hsl(${Math.random() * 360}, 50%, 50%)`;
ctx.fill();
}
// Report completion
self.postMessage({ type: 'frameComplete', timestamp: performance.now() });
}
function startAnimation() {
const animate = () => {
renderFrame({});
animationId = requestAnimationFrame(animate);
};
animate();
}
function stopAnimation() {
if (animationId !== null) {
cancelAnimationFrame(animationId);
animationId = null;
}
}
function resizeCanvas(width: number, height: number) {
if (!canvas) return;
canvas.width = width;
canvas.height = height;
renderFrame({});
}React Hook for OffscreenCanvas
const useOffscreenCanvas = (
workerPath: string,
options?: OffscreenCanvasOptions
) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const workerRef = useRef<Worker | null>(null);
const [isReady, setIsReady] = useState(false);
const [fps, setFps] = useState(0);
useEffect(() => {
if (!canvasRef.current) return;
// Check for OffscreenCanvas support
if (!('transferControlToOffscreen' in canvasRef.current)) {
console.warn('OffscreenCanvas not supported, falling back to main thread');
return;
}
// Create worker
const worker = new Worker(workerPath);
workerRef.current = worker;
// Transfer canvas control
const offscreen = canvasRef.current.transferControlToOffscreen();
worker.postMessage(
{ type: 'init', data: { canvas: offscreen } },
[offscreen]
);
// Handle worker messages
let frameCount = 0;
let lastTime = performance.now();
worker.addEventListener('message', (event) => {
const { type, data } = event.data;
if (type === 'ready') {
setIsReady(true);
} else if (type === 'frameComplete') {
frameCount++;
const now = performance.now();
if (now - lastTime >= 1000) {
setFps(frameCount);
frameCount = 0;
lastTime = now;
}
} else if (type === 'error') {
console.error('Worker error:', data);
}
});
// Start animation if requested
if (options?.autoStart) {
worker.postMessage({ type: 'startAnimation' });
}
return () => {
worker.postMessage({ type: 'stopAnimation' });
worker.terminate();
};
}, [workerPath, options]);
const sendMessage = useCallback((type: string, data?: any) => {
workerRef.current?.postMessage({ type, data });
}, []);
return {
canvasRef,
isReady,
fps,
sendMessage
};
};
interface OffscreenCanvasOptions {
autoStart?: boolean;
width?: number;
height?: number;
}
// Usage in component
const VisualizationComponent: React.FC = () => {
const { canvasRef, isReady, fps, sendMessage } = useOffscreenCanvas(
'/workers/canvas.worker.js',
{ autoStart: true }
);
return (
<div>
<canvas ref={canvasRef} width={800} height={600} />
<div>Status: {isReady ? 'Ready' : 'Loading'}</div>
<div>FPS: {fps}</div>
<button onClick={() => sendMessage('startAnimation')}>Start</button>
<button onClick={() => sendMessage('stopAnimation')}>Stop</button>
</div>
);
};WebGL with OffscreenCanvas
High-Performance WebGL Rendering
// webgl.worker.ts
class WebGLRenderer {
private canvas: OffscreenCanvas;
private gl: WebGL2RenderingContext;
private program: WebGLProgram | null = null;
private buffers: Map<string, WebGLBuffer> = new Map();
private uniforms: Map<string, WebGLUniformLocation> = new Map();
constructor(canvas: OffscreenCanvas) {
this.canvas = canvas;
const gl = canvas.getContext('webgl2');
if (!gl) {
throw new Error('WebGL2 not supported');
}
this.gl = gl;
this.initialize();
}
private initialize() {
const gl = this.gl;
// Vertex shader
const vertexShaderSource = `#version 300 es
in vec3 a_position;
in vec3 a_color;
uniform mat4 u_matrix;
uniform float u_time;
out vec3 v_color;
void main() {
vec3 pos = a_position;
pos.x += sin(u_time + a_position.y * 0.1) * 0.1;
pos.y += cos(u_time + a_position.x * 0.1) * 0.1;
gl_Position = u_matrix * vec4(pos, 1.0);
gl_PointSize = 2.0;
v_color = a_color;
}
`;
// Fragment shader
const fragmentShaderSource = `#version 300 es
precision highp float;
in vec3 v_color;
out vec4 fragColor;
void main() {
vec2 coord = gl_PointCoord - vec2(0.5);
if (length(coord) > 0.5) {
discard;
}
fragColor = vec4(v_color, 1.0);
}
`;
// Compile shaders
const vertexShader = this.compileShader(gl.VERTEX_SHADER, vertexShaderSource);
const fragmentShader = this.compileShader(gl.FRAGMENT_SHADER, fragmentShaderSource);
// Create program
this.program = this.createProgram(vertexShader, fragmentShader);
// Get uniform locations
this.uniforms.set('u_matrix', gl.getUniformLocation(this.program, 'u_matrix')!);
this.uniforms.set('u_time', gl.getUniformLocation(this.program, 'u_time')!);
// Setup GL state
gl.enable(gl.DEPTH_TEST);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
}
private compileShader(type: number, source: string): WebGLShader {
const gl = this.gl;
const shader = gl.createShader(type)!;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
const info = gl.getShaderInfoLog(shader);
gl.deleteShader(shader);
throw new Error(`Shader compilation failed: ${info}`);
}
return shader;
}
private createProgram(vertexShader: WebGLShader, fragmentShader: WebGLShader): WebGLProgram {
const gl = this.gl;
const program = gl.createProgram()!;
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
const info = gl.getProgramInfoLog(program);
gl.deleteProgram(program);
throw new Error(`Program linking failed: ${info}`);
}
return program;
}
renderParticles(particles: Float32Array, colors: Float32Array, time: number) {
const gl = this.gl;
// Clear
gl.viewport(0, 0, this.canvas.width, this.canvas.height);
gl.clearColor(0.1, 0.1, 0.1, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
if (!this.program) return;
// Use program
gl.useProgram(this.program);
// Create/update position buffer
let positionBuffer = this.buffers.get('position');
if (!positionBuffer) {
positionBuffer = gl.createBuffer()!;
this.buffers.set('position', positionBuffer);
}
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, particles, gl.DYNAMIC_DRAW);
const positionLoc = gl.getAttribLocation(this.program, 'a_position');
gl.enableVertexAttribArray(positionLoc);
gl.vertexAttribPointer(positionLoc, 3, gl.FLOAT, false, 0, 0);
// Create/update color buffer
let colorBuffer = this.buffers.get('color');
if (!colorBuffer) {
colorBuffer = gl.createBuffer()!;
this.buffers.set('color', colorBuffer);
}
gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
gl.bufferData(gl.ARRAY_BUFFER, colors, gl.DYNAMIC_DRAW);
const colorLoc = gl.getAttribLocation(this.program, 'a_color');
gl.enableVertexAttribArray(colorLoc);
gl.vertexAttribPointer(colorLoc, 3, gl.FLOAT, false, 0, 0);
// Set uniforms
const matrix = this.createPerspectiveMatrix();
gl.uniformMatrix4fv(this.uniforms.get('u_matrix')!, false, matrix);
gl.uniform1f(this.uniforms.get('u_time')!, time);
// Draw
gl.drawArrays(gl.POINTS, 0, particles.length / 3);
}
private createPerspectiveMatrix(): Float32Array {
// Simple perspective matrix
const aspect = this.canvas.width / this.canvas.height;
const fov = Math.PI / 4;
const near = 0.1;
const far = 100;
const f = Math.tan(Math.PI * 0.5 - 0.5 * fov);
const rangeInv = 1.0 / (near - far);
return new Float32Array([
f / aspect,
0,
0,
0,
0,
f,
0,
0,
0,
0,
(near + far) * rangeInv,
-1,
0,
0,
near * far * rangeInv * 2,
0,
]);
}
resize(width: number, height: number) {
this.canvas.width = width;
this.canvas.height = height;
this.gl.viewport(0, 0, width, height);
}
dispose() {
const gl = this.gl;
// Clean up buffers
this.buffers.forEach((buffer) => gl.deleteBuffer(buffer));
this.buffers.clear();
// Clean up program
if (this.program) {
gl.deleteProgram(this.program);
this.program = null;
}
}
}
// Worker message handler
let renderer: WebGLRenderer | null = null;
let animating = false;
self.addEventListener('message', (event) => {
const { type, data } = event.data;
switch (type) {
case 'init':
renderer = new WebGLRenderer(data.canvas);
self.postMessage({ type: 'ready' });
break;
case 'render':
if (renderer) {
renderer.renderParticles(data.particles, data.colors, data.time);
self.postMessage({ type: 'frameComplete' });
}
break;
case 'startAnimation':
animating = true;
animate();
break;
case 'stopAnimation':
animating = false;
break;
case 'resize':
renderer?.resize(data.width, data.height);
break;
case 'dispose':
renderer?.dispose();
renderer = null;
break;
}
});
function animate() {
if (!animating || !renderer) return;
// Generate particle data
const particleCount = 10000;
const particles = new Float32Array(particleCount * 3);
const colors = new Float32Array(particleCount * 3);
for (let i = 0; i < particleCount; i++) {
const angle = (i / particleCount) * Math.PI * 2;
const radius = Math.random() * 5;
particles[i * 3] = Math.cos(angle) * radius;
particles[i * 3 + 1] = Math.sin(angle) * radius;
particles[i * 3 + 2] = (Math.random() - 0.5) * 2;
colors[i * 3] = Math.random();
colors[i * 3 + 1] = Math.random();
colors[i * 3 + 2] = Math.random();
}
renderer.renderParticles(particles, colors, performance.now() / 1000);
self.postMessage({ type: 'frameComplete' });
requestAnimationFrame(animate);
}Complex Data Visualization
Chart Rendering with OffscreenCanvas
// chart.worker.ts
class ChartRenderer {
private canvas: OffscreenCanvas;
private ctx: OffscreenCanvasRenderingContext2D;
private data: ChartData | null = null;
constructor(canvas: OffscreenCanvas) {
this.canvas = canvas;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Could not get 2D context');
}
this.ctx = ctx;
}
updateData(data: ChartData) {
this.data = data;
this.render();
}
private render() {
if (!this.data) return;
const { width, height } = this.canvas;
const ctx = this.ctx;
// Clear canvas
ctx.clearRect(0, 0, width, height);
// Render based on chart type
switch (this.data.type) {
case 'scatter':
this.renderScatterPlot();
break;
case 'heatmap':
this.renderHeatmap();
break;
case 'line':
this.renderLineChart();
break;
}
self.postMessage({
type: 'renderComplete',
stats: {
pointsRendered: this.data.points.length,
renderTime: performance.now(),
},
});
}
private renderScatterPlot() {
if (!this.data) return;
const { width, height } = this.canvas;
const { points, colorScale } = this.data;
// Use ImageData for better performance with many points
const imageData = this.ctx.createImageData(width, height);
const pixels = imageData.data;
points.forEach((point) => {
const x = Math.floor(point.x * width);
const y = Math.floor((1 - point.y) * height);
if (x >= 0 && x < width && y >= 0 && y < height) {
const index = (y * width + x) * 4;
const color = colorScale(point.value);
pixels[index] = color.r;
pixels[index + 1] = color.g;
pixels[index + 2] = color.b;
pixels[index + 3] = 255;
}
});
this.ctx.putImageData(imageData, 0, 0);
}
private renderHeatmap() {
if (!this.data) return;
const { width, height } = this.canvas;
const { grid, colorScale } = this.data;
const cellWidth = width / grid[0].length;
const cellHeight = height / grid.length;
grid.forEach((row, y) => {
row.forEach((value, x) => {
const color = colorScale(value);
this.ctx.fillStyle = `rgb(${color.r}, ${color.g}, ${color.b})`;
this.ctx.fillRect(x * cellWidth, y * cellHeight, cellWidth, cellHeight);
});
});
}
private renderLineChart() {
if (!this.data) return;
const { width, height } = this.canvas;
const { series } = this.data;
this.ctx.strokeStyle = '#3b82f6';
this.ctx.lineWidth = 2;
series.forEach((line) => {
this.ctx.beginPath();
line.points.forEach((point, i) => {
const x = (point.x / line.maxX) * width;
const y = height - (point.y / line.maxY) * height;
if (i === 0) {
this.ctx.moveTo(x, y);
} else {
this.ctx.lineTo(x, y);
}
});
this.ctx.stroke();
});
}
}
interface ChartData {
type: 'scatter' | 'heatmap' | 'line';
points?: Array<{ x: number; y: number; value: number }>;
grid?: number[][];
series?: Array<{
points: Array<{ x: number; y: number }>;
maxX: number;
maxY: number;
}>;
colorScale: (value: number) => { r: number; g: number; b: number };
}React Component for Data Visualization
const DataVisualization: React.FC<{ data: any[] }> = ({ data }) => {
const workerRef = useRef<Worker | null>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [renderStats, setRenderStats] = useState<RenderStats | null>(null);
useEffect(() => {
if (!canvasRef.current) return;
// Check for OffscreenCanvas support
if (!('transferControlToOffscreen' in canvasRef.current)) {
// Fallback to main thread rendering
renderOnMainThread(canvasRef.current, data);
return;
}
// Create worker
const worker = new Worker('/workers/chart.worker.js');
workerRef.current = worker;
// Transfer canvas
const offscreen = canvasRef.current.transferControlToOffscreen();
worker.postMessage(
{ type: 'init', canvas: offscreen },
[offscreen]
);
// Handle worker messages
worker.addEventListener('message', (event) => {
if (event.data.type === 'renderComplete') {
setRenderStats(event.data.stats);
}
});
return () => {
worker.terminate();
};
}, []);
useEffect(() => {
if (!workerRef.current) return;
// Process data for visualization
const processed = processDataForVisualization(data);
// Send to worker
workerRef.current.postMessage({
type: 'updateData',
data: processed
});
}, [data]);
return (
<div className="visualization-container">
<canvas
ref={canvasRef}
width={1200}
height={600}
className="visualization-canvas"
/>
{renderStats && (
<div className="render-stats">
Points: {renderStats.pointsRendered.toLocaleString()} |
Time: {renderStats.renderTime.toFixed(2)}ms
</div>
)}
</div>
);
};
interface RenderStats {
pointsRendered: number;
renderTime: number;
}
function processDataForVisualization(data: any[]): ChartData {
// Transform data for visualization
const points = data.map(d => ({
x: d.x / 100,
y: d.y / 100,
value: d.value
}));
const colorScale = (value: number) => {
const hue = value * 360;
// Convert HSL to RGB
const c = 0.5;
const x = c * (1 - Math.abs((hue / 60) % 2 - 1));
const m = 0.5;
let r = 0, g = 0, b = 0;
if (hue < 60) {
r = c; g = x; b = 0;
} else if (hue < 120) {
r = x; g = c; b = 0;
} else if (hue < 180) {
r = 0; g = c; b = x;
} else if (hue < 240) {
r = 0; g = x; b = c;
} else if (hue < 300) {
r = x; g = 0; b = c;
} else {
r = c; g = 0; b = x;
}
return {
r: Math.floor((r + m) * 255),
g: Math.floor((g + m) * 255),
b: Math.floor((b + m) * 255)
};
};
return {
type: 'scatter',
points,
colorScale
};
}
function renderOnMainThread(canvas: HTMLCanvasElement, data: any[]) {
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Fallback rendering on main thread
console.warn('OffscreenCanvas not supported, rendering on main thread');
// Simple rendering implementation
ctx.clearRect(0, 0, canvas.width, canvas.height);
data.forEach(point => {
ctx.beginPath();
ctx.arc(point.x, point.y, 2, 0, Math.PI * 2);
ctx.fillStyle = `hsl(${point.value * 360}, 50%, 50%)`;
ctx.fill();
});
}Performance Monitoring
class CanvasPerformanceMonitor {
private metrics: PerformanceMetrics = {
fps: 0,
frameTime: 0,
workerTime: 0,
transferTime: 0,
renderTime: 0,
};
private frameTimings: number[] = [];
private lastFrameTime = 0;
startFrame(): FrameTimer {
const startTime = performance.now();
return {
markTransferStart: () => {
this.metrics.transferTime = performance.now() - startTime;
},
markWorkerStart: () => {
const workerStart = performance.now();
return () => {
this.metrics.workerTime = performance.now() - workerStart;
};
},
markRenderComplete: () => {
const frameTime = performance.now() - startTime;
this.metrics.frameTime = frameTime;
this.updateFPS(frameTime);
this.reportIfSlow(frameTime);
},
};
}
private updateFPS(frameTime: number) {
this.frameTimings.push(frameTime);
// Keep last 60 frames
if (this.frameTimings.length > 60) {
this.frameTimings.shift();
}
// Calculate average FPS
const avgFrameTime = this.frameTimings.reduce((a, b) => a + b, 0) / this.frameTimings.length;
this.metrics.fps = 1000 / avgFrameTime;
}
private reportIfSlow(frameTime: number) {
if (frameTime > 16.67) {
// Slower than 60fps
console.warn(`Slow frame detected: ${frameTime.toFixed(2)}ms`, {
workerTime: this.metrics.workerTime,
transferTime: this.metrics.transferTime,
});
}
}
getMetrics(): PerformanceMetrics {
return { ...this.metrics };
}
}
interface PerformanceMetrics {
fps: number;
frameTime: number;
workerTime: number;
transferTime: number;
renderTime: number;
}
interface FrameTimer {
markTransferStart: () => void;
markWorkerStart: () => () => void;
markRenderComplete: () => void;
}Best Practices Checklist
✅ Use OffscreenCanvas for:
- Complex visualizations
- Real-time data rendering
- Image/video processing
- 3D graphics and games
✅ Optimize worker communication:
- Use transferable objects
- Batch updates when possible
- Minimize message size
- Avoid frequent back-and-forth
✅ Handle fallbacks:
- Check for support
- Provide main thread fallback
- Test on various devices
- Monitor performance
✅ Manage resources:
- Clean up workers
- Dispose WebGL resources
- Clear large buffers
- Terminate unused workers
✅ Profile performance:
- Monitor frame times
- Track worker utilization
- Measure transfer overhead
- Identify bottlenecks