← Back to Blog

Web Workers: Keeping the UI Responsive While Calculating 100-Hop Routes

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:

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:

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:

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:

  1. Point-to-point routing (routing_worker.ts): A* and Dijkstra pathfinding
  2. Multi-waypoint optimization (scout_optimizer_worker.ts): Traveling Salesman Problem (TSP) solving
  3. 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):

Worker thread (routing_worker.ts):

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:

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):

Long route (100 hops):

Scout Optimizer (4-worker pool)

10-waypoint route:

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:

❌ Avoid Workers For:

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):

  1. Select a distant start and end system
  2. Watch the progress counter update in real-time
  3. Try panning/zooming the map while the route calculates—UI stays smooth!
  4. 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

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!

web workersbackground computationpathfindingperformanceparallel processing