Web Workers for game logic
Web Workers let you run JavaScript in a background thread. For games, this means physics, AI, and procedural generation can run without dropping frames.
1) When to use Workers
Good candidates:
- Physics simulation
- Pathfinding (A*, navmesh)
- AI decision making
- Procedural generation
- Asset processing (image manipulation, compression)
- Complex math (FFT, collision detection)
Not ideal for:
- Rendering (Workers can't access DOM/Canvas directly)
- Very small tasks (thread overhead isn't worth it)
2) Creating a basic Worker
worker.js:
self.onmessage = (e) => {
const { type, data } = e.data
if (type === 'calculate') {
const result = heavyCalculation(data)
self.postMessage({ type: 'result', data: result })
}
}
function heavyCalculation(input) {
// Expensive work here
return input * 2
}main.js:
const worker = new Worker('worker.js')
worker.onmessage = (e) => {
const { type, data } = e.data
if (type === 'result') {
console.log('Got result:', data)
}
}
worker.postMessage({ type: 'calculate', data: 42 })3) Inline Workers (no separate file)
function createInlineWorker(fn) {
const blob = new Blob([`(${fn.toString()})()`], { type: 'text/javascript' })
return new Worker(URL.createObjectURL(blob))
}
const worker = createInlineWorker(() => {
self.onmessage = (e) => {
const result = e.data * 2
self.postMessage(result)
}
})4) ES module Workers
You can write worker code as an ES module and use import inside it, instead of importScripts. Pass { type: 'module' } to the constructor:
const worker = new Worker('physics-worker.js', { type: 'module' })Inside the worker you can then import shared math, collision, or AI helpers the same way you do on the main thread, so game logic isn't duplicated. Module workers are supported in Chrome and Edge 80+, Safari 15+, and Firefox 114+.
5) Physics Worker example
physics-worker.js:
const bodies = []
const FIXED_DT = 1 / 60
self.onmessage = (e) => {
const { type, data } = e.data
switch (type) {
case 'init':
initWorld(data)
break
case 'step':
step()
break
case 'addBody':
bodies.push(data)
break
}
}
function initWorld(config) {
// Initialize physics world
}
function step() {
// Update physics
for (const body of bodies) {
body.vy += 9.8 * FIXED_DT // Gravity
body.x += body.vx * FIXED_DT
body.y += body.vy * FIXED_DT
}
// Send positions back
self.postMessage({
type: 'positions',
data: bodies.map(b => ({ id: b.id, x: b.x, y: b.y, rotation: b.rotation }))
})
}6) Transferable objects for performance
Large data can be transferred without copying:
// Main thread
const positions = new Float32Array(1000)
worker.postMessage(positions, [positions.buffer])
// positions is now unusable here (transferred)
// Worker
self.onmessage = (e) => {
const positions = e.data
// Work with positions
self.postMessage(positions, [positions.buffer])
}7) Pathfinding Worker
pathfinding-worker.js:
let grid = null
self.onmessage = (e) => {
const { type, data } = e.data
if (type === 'setGrid') {
grid = data
}
if (type === 'findPath') {
const path = aStar(data.start, data.end, grid)
self.postMessage({ type: 'path', id: data.id, path })
}
}
function aStar(start, end, grid) {
// A* implementation
const openSet = [start]
const cameFrom = new Map()
const gScore = new Map()
gScore.set(key(start), 0)
while (openSet.length > 0) {
// ... A* logic
}
return reconstructPath(cameFrom, end)
}
function key(pos) {
return `${pos.x},${pos.y}`
}8) Worker pool for parallel tasks
class WorkerPool {
constructor(workerUrl, size = navigator.hardwareConcurrency || 4) {
this.workers = []
this.queue = []
this.available = []
for (let i = 0; i < size; i++) {
const worker = new Worker(workerUrl)
worker.onmessage = (e) => this.handleResult(worker, e)
this.workers.push(worker)
this.available.push(worker)
}
}
run(data) {
return new Promise((resolve) => {
const task = { data, resolve }
if (this.available.length > 0) {
this.dispatch(this.available.pop(), task)
} else {
this.queue.push(task)
}
})
}
dispatch(worker, task) {
worker._currentTask = task
worker.postMessage(task.data)
}
handleResult(worker, e) {
const task = worker._currentTask
task.resolve(e.data)
if (this.queue.length > 0) {
this.dispatch(worker, this.queue.shift())
} else {
this.available.push(worker)
}
}
terminate() {
this.workers.forEach(w => w.terminate())
}
}9) SharedArrayBuffer for real-time sync
With cross-origin isolation enabled, you can share memory:
// Main thread
const shared = new SharedArrayBuffer(1024)
const positions = new Float32Array(shared)
worker.postMessage({ type: 'init', buffer: shared })
// Worker reads/writes directly to shared memory
// No postMessage overhead for position updates10) OffscreenCanvas for Workers
Render in a Worker. OffscreenCanvas is now Baseline Widely available across Chrome, Edge, Firefox, and Safari (Safari added support in 17.0 on macOS and iOS):
// Main thread
const canvas = document.getElementById('game')
const offscreen = canvas.transferControlToOffscreen()
worker.postMessage({ canvas: offscreen }, [offscreen])
// Worker
self.onmessage = (e) => {
const canvas = e.data.canvas
const ctx = canvas.getContext('2d')
function render() {
ctx.clearRect(0, 0, canvas.width, canvas.height)
// Draw...
requestAnimationFrame(render)
}
render()
}11) Error handling
worker.onerror = (e) => {
console.error('Worker error:', e.message, e.filename, e.lineno)
}
// In worker
self.onerror = (e) => {
self.postMessage({ type: 'error', message: e.message })
}Related
- Ship a web game that loads fast
- Enable Wasm threads (SharedArrayBuffer)
- Canvas 2D game loop
- Game physics libraries — running Rapier or Cannon-es in a Worker
- Streaming asset loading — decoding assets off the main thread
- Web Games Tech Stack in 2026 — how Workers fit into the WebGL/WebGPU/Wasm stack
External Resources
- MDN: Web Workers API — full API reference
- MDN: Using Web Workers — step-by-step guide
- MDN: Transferable objects — zero-copy data transfer
- MDN: SharedArrayBuffer — shared memory between threads