When you calculate a route across 100 star systems in EF-Map, the browser performs hundreds of thousands of distance calculations, neighbor lookups, and priority queue operations. If this happened on the main JavaScript thread, your UI would freeze for seconds—no panning, no zoom, no interaction.
Instead, EF-Map uses Web Workers—dedicated background threads that crunch numbers while the main thread stays buttery smooth. This post explains how we architected our worker system, the message protocols we use, and why this pattern is essential for modern web apps.
The Problem: JavaScript is Single-Threaded
JavaScript runs on a single main thread in the browser. That thread handles:
- Rendering: Drawing pixels, running CSS animations, updating the DOM
- Event handling: Mouse clicks, keyboard input, scroll events
- JavaScript execution: Your app logic, data processing, API calls
When you run heavy computation (like A* pathfinding over 200,000 systems), that thread is blocked—nothing else can happen until the calculation finishes.
Symptoms of main-thread blocking:
- UI freezes (can't click buttons)
- Animations stutter or stop
- Browser shows "Page Unresponsive" warning
- Users assume the app crashed
For EF-Map's routing engine, a medium-distance route (40 jumps) can explore 8,000+ systems and take 180ms. If that runs on the main thread, the app is frozen for nearly 200ms—unacceptable for a responsive UX.
The Solution: Web Workers
Web Workers are browser-native background threads. They run JavaScript in parallel with the main thread, allowing you to:
- Offload heavy computation to a worker
- Keep the main thread responsive for UI updates
- Send results back when the worker finishes
The worker runs in a separate JavaScript context—it can't access the DOM, window, or React state. It communicates with the main thread via message passing (structured clones of data).
How Workers Fit into EF-Map
We use workers for three main computation-heavy tasks:
- Point-to-point routing (
routing_worker.ts): A* and Dijkstra pathfinding - Multi-waypoint optimization (
scout_optimizer_worker.ts): Traveling Salesman Problem (TSP) solving - Region statistics aggregation (
region_stats_worker.ts): Spatial queries over large datasets (future)
Routing Worker: A* Pathfinding in the Background
The Architecture
Main thread (App.tsx):
- User clicks "Calculate Route"
- Sends request to
routing_worker.tsviapostMessage({ systems, stargates, fromSystemName, toSystemName, ... }) - Continues rendering, animating, handling user input
- Receives result via
worker.onmessagewhen pathfinding completes
Worker thread (routing_worker.ts):
- Receives request
- Loads star system graph (systems, stargates, Smart Gates)
- Runs A* or Dijkstra algorithm
- Sends progress updates every ~200ms (
{ type: 'progress', explored: 1200, frontier: 85, ... }) - Sends final result
{ path: ['System A', 'System B', ...], ... }
Message Protocol
Request (main → worker):
interface RoutingRequest {
systems: Record<string, SolarSystem>;
stargates: Record<string, Stargate>;
fromSystemName: string;
toSystemName: string;
maxJumpDistance: number;
optimizeFor: 'fuel' | 'jumps' | 'explore';
algorithm?: 'astar' | 'dijkstra';
overheadPct?: number; // For explore mode
smartGateEdges?: Array<{ from: string; to: string; cost: number }>;
}
Progress (worker → main):
interface RoutingProgress {
type: 'progress';
explored: number; // Systems visited
frontier: number; // Systems queued for exploration
elapsedMs: number; // Time since start
message?: string; // Debug message
}
Response (worker → main):
interface RoutingResponse {
path: string[] | null; // System names in order
error?: string; // Error message if failed
minRequiredShipRange?: number; // If path failed due to jump range
meta?: {
baselineCost: number;
finalCost: number;
baselineNodes: number;
finalNodes: number;
};
}
Worker Initialization
On app load, we create the worker and wire its message handler:
useEffect(() => {
const worker = new Worker(new URL('./utils/routing_worker.ts', import.meta.url), { type: 'module' });
routingWorkerRef.current = worker;
worker.onmessage = (e) => {
const data = e.data;
// Handle progress updates (stream to UI state)
if (data && data.type === 'progress') {
setRouteProgress({
explored: data.explored ?? 0,
frontier: data.frontier ?? 0,
elapsedMs: data.elapsedMs ?? 0,
message: data.message ?? ''
});
return;
}
// Handle final result
const { path, error, minRequiredShipRange, meta } = data;
setIsCalculatingRoute(false);
setRouteProgress(null);
if (error || !path) {
setRouteResult({ path: null, error, minRequiredShipRange });
} else {
setRouteResult({ path, meta });
}
};
return () => worker.terminate(); // Cleanup on unmount
}, [mapData]);
Sending a Route Request
When the user clicks "Calculate Route":
const calculateRoute = (from: string, to: string) => {
setIsCalculatingRoute(true);
setRouteProgress({ explored: 0, frontier: 0, elapsedMs: 0 });
routingWorkerRef.current.postMessage({
systems: mapData.solar_systems,
stargates: mapData.stargates,
fromSystemName: from,
toSystemName: to,
maxJumpDistance: parseFloat(shipMaxRange) || 7.0,
optimizeFor: 'fuel',
algorithm: 'astar',
smartGateEdges: buildSmartGateEdges(),
});
};
The main thread immediately returns—no blocking. The UI stays responsive while the worker crunches numbers.
Inside the Worker: A* Implementation
The worker runs the full A* algorithm:
self.onmessage = (e: MessageEvent<RoutingRequest>) => {
try {
const result = findPath(e.data);
self.postMessage(result);
} catch (error) {
self.postMessage({
path: null,
error: error instanceof Error ? error.message : 'Unknown error'
});
}
};
function findPath(request: RoutingRequest): RoutingResponse {
const { systems, stargates, fromSystemName, toSystemName, maxJumpDistance, optimizeFor } = request;
// Initialize priority queue, visited set, distance map
const heap = new MinHeap<System>();
const visited: Record<string, boolean> = {};
const distances = new Map<string, number>();
const previous = new Map<string, System>();
const startNode = findSystemByName(fromSystemName, systems);
const endNode = findSystemByName(toSystemName, systems);
if (!startNode || !endNode) {
return { path: null, error: 'System not found' };
}
heap.push({ val: startNode, priority: 0 });
distances.set(startNode.id, 0);
let progressCounter = 0;
const progressInterval = 500; // Send progress every 500 iterations
while (!heap.isEmpty()) {
const current = heap.pop()!.val;
if (visited[current.id]) continue;
visited[current.id] = true;
// Send progress update every ~200ms
if (++progressCounter % progressInterval === 0) {
self.postMessage({
type: 'progress',
explored: Object.keys(visited).length,
frontier: heap.size(),
elapsedMs: Date.now() - startTime,
});
}
if (current.id === endNode.id) {
// Reconstruct path
const path = reconstructPath(previous, current);
return { path };
}
// Explore neighbors (stargates + ship jumps within maxJumpDistance)
for (const neighbor of getNeighbors(current, systems, stargates, maxJumpDistance)) {
const cost = distances.get(current.id)! + edgeCost(current, neighbor, optimizeFor);
if (!distances.has(neighbor.id) || cost < distances.get(neighbor.id)!) {
distances.set(neighbor.id, cost);
previous.set(neighbor.id, current);
const priority = cost + heuristic(neighbor, endNode);
heap.push({ val: neighbor, priority });
}
}
}
return { path: null, error: 'No path found' };
}
Key optimizations:
- Spatial grid cache: Neighbor lookups use a pre-built grid (O(1) instead of O(n))
- Progress throttling: Updates sent every 500 iterations (~200ms intervals) to avoid spamming the main thread
- Early termination: If goal is reached, immediately return the path
Progress Updates in the UI
As the worker sends progress messages, the main thread updates a live counter:
{isCalculatingRoute && routeProgress && (
<div className="route-progress">
<p>
Explored: {routeProgress.explored} systems |
Frontier: {routeProgress.frontier} |
{(routeProgress.elapsedMs / 1000).toFixed(1)}s
</p>
{routeProgress.message && <small>{routeProgress.message}</small>}
</div>
)}
Result: Users see a live progress bar showing exploration advancing—much better UX than a frozen spinner.
Scout Optimizer Worker: TSP Solving
The Scout Optimizer tackles the Traveling Salesman Problem—finding the shortest route through multiple waypoints. This is computationally expensive (NP-hard), so we use a worker pool (multiple workers running in parallel).
Worker Pool Pattern
Instead of one worker, we spawn multiple workers (typically 4) to run genetic algorithm variants simultaneously:
const workers = Array.from({ length: 4 }, () =>
new Worker(new URL('./workers/scout_optimizer_worker.ts', import.meta.url), { type: 'module' })
);
workers.forEach((worker, index) => {
worker.onmessage = (e) => {
if (e.data.type === 'optimizeResult') {
handleCandidatePath(e.data.path, index);
}
};
});
// Broadcast baseline request to all workers
const startOptimization = (waypoints: string[]) => {
workers.forEach(worker => {
worker.postMessage({
type: 'baseline',
start: waypoints[0],
systems: waypoints,
returnToStart: true,
generation: currentGeneration++,
});
});
};
Each worker runs different randomized search heuristics (2-opt swaps, segment reversals, random mutations). The main thread compares results and keeps the champion path (lowest cost).
Message Protocol for Optimizer
Baseline request:
{
type: 'baseline',
start: string,
systems: string[],
returnToStart: boolean,
maxShipRange: number,
generation: number,
}
Optimize request:
{
type: 'optimize',
path: string[],
passes: number,
timePerPassSec: number,
returnToStart: boolean,
generation: number,
}
Progress:
{
type: 'progress',
message: string, // e.g., "Pass 3/10 improvement: 45% cost reduction"
}
Result:
{
type: 'optimizeResult',
path: string[],
shipDistance: number,
shipJumps: number,
totalDistance: number,
generation: number,
}
Worker-Side: Iterative Improvement
Inside scout_optimizer_worker.ts:
self.onmessage = (e) => {
if (e.data.type === 'baseline') {
// Build initial path using nearest-neighbor heuristic
const baseline = nearestNeighborPath(e.data.start, e.data.systems);
self.postMessage({ type: 'baselineResult', path: baseline, generation: e.data.generation });
} else if (e.data.type === 'optimize') {
// Run iterative improvement (2-opt, random mutations)
const champion = iterativeImprove(e.data.path, e.data.passes, e.data.timePerPassSec);
const cost = computePathCost(champion);
self.postMessage({
type: 'optimizeResult',
path: champion,
shipDistance: cost.shipDistance,
generation: e.data.generation
});
}
};
function iterativeImprove(path: string[], passes: number, timePerPassSec: number): string[] {
let champion = path.slice();
let championCost = computePathCost(champion);
for (let p = 0; p < passes; p++) {
// Mutate: reverse a random segment
const mutated = champion.slice();
const i = 1 + Math.floor(Math.random() * (mutated.length - 3));
const j = i + 2 + Math.floor(Math.random() * (mutated.length - i - 2));
mutated.splice(i, j - i, ...mutated.slice(i, j).reverse());
// Refine: 2-opt local search
const refined = twoOpt(mutated, 250); // 250ms budget
const refinedCost = computePathCost(refined);
// Accept if better
if (refinedCost.shipDistance < championCost.shipDistance) {
champion = refined;
championCost = refinedCost;
self.postMessage({ type: 'progress', message: `Pass ${p + 1}: Improved to ${championCost.shipDistance.toFixed(2)} LY` });
}
}
return champion;
}
Key insight: Each worker runs independently, exploring different mutation sequences. The main thread aggregates results and keeps the best path across all workers.
Performance Benefits: By the Numbers
Routing Worker
Medium route (40 hops):
- With worker: Main thread blocked 0ms, route calculated in 180ms background
- Without worker: Main thread blocked 180ms, UI frozen
Long route (100 hops):
- With worker: Main thread blocked 0ms, route calculated in 2,500ms background
- Without worker: Main thread blocked 2,500ms—browser "Page Unresponsive" warning appears
Scout Optimizer (4-worker pool)
10-waypoint route:
- Single worker: 8 seconds to find near-optimal path
- 4-worker pool: 3 seconds (workers compete, best path wins)
Speedup: ~2.7x from parallelization (not perfect 4x due to coordination overhead).
Challenges and Gotchas
Challenge 1: Workers Can't Access DOM
Workers run in a separate context—no window, document, or React state.
Solution: Pass all data via postMessage. For EF-Map, we serialize the entire star system graph (200k systems) on each request. This is slow (~50ms), so we cache the graph in the worker's memory after the first request:
let cachedSystems: Record<string, SolarSystem> | null = null;
self.onmessage = (e) => {
if (!cachedSystems) {
cachedSystems = e.data.systems;
}
// Use cachedSystems for all calculations
};
Challenge 2: Message Passing Overhead
Structured cloning (how postMessage works) has cost. For large objects (like 200k systems), cloning can take 30-50ms.
Solution: Send data once on worker init, then send only lightweight request IDs:
// Init: Send full graph
worker.postMessage({ type: 'init', systems, stargates });
// Route request: Send only system names
worker.postMessage({ type: 'find-path', from: 'Jita', to: 'Amarr' });
Challenge 3: Progress Updates Flood Main Thread
Sending a progress message on every iteration (millions per route) would overwhelm the main thread.
Solution: Throttle progress to ~200ms intervals:
let lastProgressTime = 0;
while (!heap.isEmpty()) {
// ... pathfinding logic ...
const now = Date.now();
if (now - lastProgressTime >= 200) {
self.postMessage({ type: 'progress', explored: visitedCount, frontier: heap.size() });
lastProgressTime = now;
}
}
Challenge 4: Worker Termination on Route Change
If the user cancels a route mid-calculation, we need to kill the worker to stop wasting CPU:
const cancelRoute = () => {
if (routingWorkerRef.current) {
routingWorkerRef.current.terminate(); // Kill the worker
// Recreate a fresh worker for next request
const newWorker = new Worker(new URL('./utils/routing_worker.ts', import.meta.url), { type: 'module' });
routingWorkerRef.current = newWorker;
// Re-wire onmessage handler...
}
};
Caveat: terminate() is immediate and brutal—the worker has no chance to clean up. For EF-Map, this is fine (no critical state to save).
When to Use Workers (and When Not To)
✅ Use Workers For:
- Heavy computation (>50ms on main thread)
- Algorithms with progress: Pathfinding, optimization, data processing
- Parallelizable tasks: Multiple independent computations (TSP worker pool)
- Background sync: Fetching and parsing large datasets
❌ Avoid Workers For:
- DOM manipulation: Workers can't touch the DOM—use
requestAnimationFrameinstead - Quick calculations (<10ms): Message passing overhead outweighs benefit
- Shared mutable state: Workers use message passing, not shared memory (unless using SharedArrayBuffer)
Future: SharedArrayBuffer and Worker Pools
We're exploring SharedArrayBuffer to avoid cloning overhead for large datasets:
// Main thread
const buffer = new SharedArrayBuffer(systemsArray.byteLength);
const sharedSystems = new Float32Array(buffer);
sharedSystems.set(systemsArray);
worker.postMessage({ type: 'init', systemsBuffer: buffer });
// Worker thread
let systemsView: Float32Array;
self.onmessage = (e) => {
if (e.data.type === 'init') {
systemsView = new Float32Array(e.data.systemsBuffer);
// Zero-copy access to system coordinates!
}
};
Benefit: No 50ms cloning penalty—worker reads directly from shared memory.
Caveat: Requires Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers (breaks some embed scenarios).
Try It Yourself
Open EF-Map's routing panel and calculate a long route (100+ jumps):
- Select a distant start and end system
- Watch the progress counter update in real-time
- Try panning/zooming the map while the route calculates—UI stays smooth!
- Check the browser console—no main-thread warnings
Compare this to other mapping tools that freeze during route calculation—the difference is night and day.
Related Posts
- A* vs Dijkstra: Choosing the Right Pathfinding Algorithm - The algorithm running inside the routing worker
- Scout Optimizer: Solving the Traveling Salesman Problem in Space - TSP worker pool deep dive
- Three.js Rendering: Building a 3D Starfield for 200,000 Systems - Why rendering also needs to stay off the main thread
Web Workers are the secret weapon behind EF-Map's responsive feel—complex routing happens in the background while you explore the stars. If you're building a data-heavy web app, workers should be in your toolkit!