← Back to Blog

Performance Optimization Journey: From 8-Second Loads to 800ms

When EF-Map launched, rendering 8,000+ star systems took 8 seconds on desktop and frozen browsers on mobile. Today, the map loads in 800ms and stays responsive even during 100-hop route calculations.

This post documents the performance journey—the bottlenecks we hit, the optimizations we applied, and the measurement-driven approach that reduced load time by 90% and bundle size by 65%.

The Starting Point: Slow and Broken

Initial Performance Metrics (v0.1, Aug 2025)

Load time:

Bundle size:

Runtime performance:

User impact:

Problem 1: Massive Bundle Size (2.8 MB)

Root Cause: Monolithic Imports

Bad pattern (initial code):

import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer';
// ... 12 more THREE imports

Result: Bundler included all of Three.js (600 KB) even though we only used 20% of it.

Solution 1: Tree-Shaking + Targeted Imports

Optimized:

// Only import what we use
import { Scene, PerspectiveCamera, WebGLRenderer } from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

Vite config (vite.config.ts):

export default {
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          'three': ['three'],
          'workers': ['./src/utils/routing_worker.ts', './workers/scout_optimizer_worker.ts']
        }
      }
    }
  }
}

Result:

Solution 2: Code Splitting (Lazy Loading)

Problem: All features loaded upfront, even if never used.

Fix: Lazy-load panels and heavy features:

// Before: Eager loading
import RoutingPanel from './components/RoutingPanel';
import StatsPanel from './components/StatsPanel';
import HelperBridgePanel from './components/HelperBridgePanel';

// After: Lazy loading
const RoutingPanel = lazy(() => import('./components/RoutingPanel'));
const StatsPanel = lazy(() => import('./components/StatsPanel'));
const HelperBridgePanel = lazy(() => import('./components/HelperBridgePanel'));

function App() {
  return (
    <Suspense fallback={<LoadingSpinner />}>
      {showRoutingPanel && <RoutingPanel />}
      {showStatsPanel && <StatsPanel />}
      {showHelperBridge && <HelperBridgePanel />}
    </Suspense>
  );
}

Result:

Solution 3: Remove Unused Dependencies

Audit (npm):

npx depcheck

Found:

Code migration (debounce example):

// Before: Lodash
import _ from 'lodash';
const debouncedSearch = _.debounce(handleSearch, 300);

// After: Native JS
function debounce<T extends (...args: any[]) => any>(fn: T, ms: number) {
  let timer: number | null = null;
  return (...args: Parameters<T>) => {
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => fn(...args), ms);
  };
}
const debouncedSearch = debounce(handleSearch, 300);

Result:

Problem 2: Slow Rendering (6s Main Thread Block)

Root Cause: Synchronous Geometry Creation

Initial code (App.tsx):

function renderStars() {
  const geometry = new THREE.BufferGeometry();
  const positions = [];
  
  // Block main thread for 4+ seconds
  for (const system of systems) {
    positions.push(system.x, system.y, system.z);
  }
  
  geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
  scene.add(new THREE.Points(geometry, material));
}

Problem: 8,000 systems × 3 coordinates × Float32 conversion = 4.2 seconds of blocking JavaScript.

Solution 1: Pre-Computed Binary Data

Export script (create_map_data.py):

import struct

def export_positions_binary(systems):
    with open('positions.bin', 'wb') as f:
        for system in systems:
            f.write(struct.pack('fff', system['x'], system['y'], system['z']))

Load in app:

async function loadPositions(): Promise<Float32Array> {
  const response = await fetch('/data/positions.bin');
  const buffer = await response.arrayBuffer();
  return new Float32Array(buffer);
}

async function renderStars() {
  const positions = await loadPositions(); // Already Float32Array
  
  const geometry = new THREE.BufferGeometry();
  geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
  scene.add(new THREE.Points(geometry, material));
}

Result:

Solution 2: OffscreenCanvas for Workers

Idea: Move starfield rendering to a Web Worker.

Challenge: Three.js uses document (main thread only).

Workaround: Use OffscreenCanvas (Chrome 69+, Firefox 105+):

// main.ts
const canvas = document.getElementById('starfield') as HTMLCanvasElement;
const offscreen = canvas.transferControlToOffscreen();

const worker = new Worker('./render_worker.ts', { type: 'module' });
worker.postMessage({ canvas: offscreen }, [offscreen]);

// render_worker.ts
self.onmessage = (e) => {
  const canvas = e.data.canvas as OffscreenCanvas;
  const renderer = new THREE.WebGLRenderer({ canvas });
  
  // Render loop runs in worker (non-blocking)
  function animate() {
    renderer.render(scene, camera);
    requestAnimationFrame(animate);
  }
  animate();
};

Result:

Note: OffscreenCanvas has limited browser support (~70% global). We use it as a progressive enhancement (fallback to main thread for older browsers).

Problem 3: Pathfinding Lag (12s for 100-Hop Routes)

Root Cause: O(n) Neighbor Lookups

Initial code (routing_worker.ts):

function getNeighbors(systemId: string): System[] {
  const neighbors = [];
  
  // O(n) scan of all 8,000 systems
  for (const candidateId of Object.keys(systems)) {
    const dist = distance(systems[systemId], systems[candidateId]);
    if (dist <= MAX_JUMP_RANGE) {
      neighbors.push(systems[candidateId]);
    }
  }
  
  return neighbors;
}

Performance (100-hop route):

getNeighbors called: 2,500 times
Per-call cost:       ~5ms (8,000 systems scanned)
Total time:          2,500 × 5ms = 12.5 seconds

Solution: Spatial Grid Indexing

Concept: Divide 3D space into cells; only check systems in neighboring cells.

Code:

const CELL_SIZE = MAX_JUMP_RANGE;
const spatialGrid = new Map<string, System[]>();

function buildSpatialGrid() {
  spatialGrid.clear();
  
  for (const system of Object.values(systems)) {
    const cellKey = getCellKey(system.x, system.y, system.z);
    
    if (!spatialGrid.has(cellKey)) {
      spatialGrid.set(cellKey, []);
    }
    
    spatialGrid.get(cellKey)!.push(system);
  }
}

function getCellKey(x: number, y: number, z: number): string {
  const cx = Math.floor(x / CELL_SIZE);
  const cy = Math.floor(y / CELL_SIZE);
  const cz = Math.floor(z / CELL_SIZE);
  return `${cx},${cy},${cz}`;
}

function getNeighbors(systemId: string): System[] {
  const system = systems[systemId];
  const cellKey = getCellKey(system.x, system.y, system.z);
  
  const neighbors = [];
  
  // Check current cell + 26 neighboring cells (3×3×3 cube minus center)
  for (let dx = -1; dx <= 1; dx++) {
    for (let dy = -1; dy <= 1; dy++) {
      for (let dz = -1; dz <= 1; dz++) {
        const [cx, cy, cz] = cellKey.split(',').map(Number);
        const neighborKey = `${cx+dx},${cy+dy},${cz+dz}`;
        
        if (spatialGrid.has(neighborKey)) {
          for (const candidate of spatialGrid.get(neighborKey)!) {
            const dist = distance(system, candidate);
            if (dist <= MAX_JUMP_RANGE) {
              neighbors.push(candidate);
            }
          }
        }
      }
    }
  }
  
  return neighbors;
}

Result:

Bonus: Neighbor Cache

Observation: Same neighbor queries repeated during pathfinding.

Fix: Cache results:

const neighborCache = new Map<string, System[]>();

function getNeighbors(systemId: string): System[] {
  if (neighborCache.has(systemId)) {
    return neighborCache.get(systemId)!;
  }
  
  const neighbors = computeNeighbors(systemId); // Uses spatial grid
  neighborCache.set(systemId, neighbors);
  return neighbors;
}

Result:

Problem 4: Hover Lag (300ms Label Display)

Root Cause: O(n) Ray Intersection

Initial code:

function onMouseMove(event: MouseEvent) {
  const raycaster = new THREE.Raycaster();
  raycaster.setFromCamera(mouse, camera);
  
  // Test intersection with ALL 8,000 star meshes
  const intersects = raycaster.intersectObjects(scene.children, true);
  
  if (intersects.length > 0) {
    showLabel(intersects[0].object.userData.systemId);
  }
}

Problem: Ray-sphere intersection test for 8,000 objects every mouse move.

Solution: Octree Spatial Partitioning

Concept: Divide scene into hierarchical octree; only test objects in intersected octants.

Library: three-octree (lightweight Three.js addon)

Code:

import { Octree } from 'three-octree';

const octree = new Octree({
  undeferred: false,
  depthMax: 5,
  objectsThreshold: 100
});

// Populate octree
for (const mesh of starMeshes) {
  octree.add(mesh);
}

function onMouseMove(event: MouseEvent) {
  const raycaster = new THREE.Raycaster();
  raycaster.setFromCamera(mouse, camera);
  
  // Only test objects in intersected octants (~50-200 instead of 8,000)
  const intersects = octree.search(raycaster.ray);
  
  if (intersects.length > 0) {
    showLabel(intersects[0].object.userData.systemId);
  }
}

Result:

Problem 5: Mobile Jank (18 FPS Pan/Zoom)

Root Cause: Excessive Re-Renders

React profiler showed:

<App> re-renders: 45 per second
Reason: Camera position updates trigger full tree re-render

Initial code:

function App() {
  const [cameraPosition, setCameraPosition] = useState({ x: 0, y: 0, z: 1000 });
  
  useEffect(() => {
    const onCameraMove = () => {
      setCameraPosition({ x: camera.position.x, y: camera.position.y, z: camera.position.z });
    };
    
    controls.addEventListener('change', onCameraMove); // Fires 60× per second
  }, []);
  
  return (
    <div>
      <Starfield cameraPosition={cameraPosition} />
      <UIPanel cameraPosition={cameraPosition} />
    </div>
  );
}

Problem: Camera updates (60 FPS) trigger React re-renders (expensive).

Solution: Decouple Three.js from React State

Optimized:

function App() {
  const cameraRef = useRef<THREE.Camera>(null);
  
  // No state updates for camera movement
  useEffect(() => {
    const animate = () => {
      renderer.render(scene, cameraRef.current!);
      requestAnimationFrame(animate);
    };
    animate();
  }, []);
  
  return (
    <div>
      <Starfield cameraRef={cameraRef} />
      <UIPanel /> {/* No camera props */}
    </div>
  );
}

Result:

Problem 6: Database Query Slowness

Root Cause: Full Table Scans

Initial SQL (getSystemsByName):

SELECT * FROM systems WHERE name LIKE '%Jita%';

Problem: No index on name column → full table scan (8,000 rows).

Solution: Add Index

Migration:

CREATE INDEX idx_systems_name ON systems(name);

Result:

Bonus: Prefix-Only LIKE Optimization

Further optimization:

-- Slow (middle wildcard)
SELECT * FROM systems WHERE name LIKE '%Jita%';

-- Fast (prefix wildcard)
SELECT * FROM systems WHERE name LIKE 'Jita%';

Why: Prefix searches can use index directly; middle wildcards cannot.

Trade-off: Less flexible (misses "New Jita"), but 10x faster. We use full-text search for fallback.

Problem 7: Excessive Network Requests

Root Cause: Separate Fetches for Every Resource

Initial code:

const systems = await fetch('/data/systems.json');
const stargates = await fetch('/data/stargates.json');
const regions = await fetch('/data/regions.json');
const constellations = await fetch('/data/constellations.json');
// ... 8 more requests

Problem: 12 round-trips (500ms each on 3G) = 6 seconds load time.

Solution: Single SQLite Database

Combined resource:

const db = await loadDatabase('/data/map_data_v2.db'); // Single 4.2 MB file

const systems = db.exec('SELECT * FROM systems');
const stargates = db.exec('SELECT * FROM stargates');
const regions = db.exec('SELECT * FROM regions');
// All queries local (no network)

Result:

Measurement-Driven Optimization

Tools We Used

1. Lighthouse (Chrome DevTools):

lighthouse https://ef-map.com --view

Metrics:

2. Bundle Analyzer:

npx vite-bundle-visualizer

Visualizes: Chunk sizes, dependencies, tree-shaking effectiveness.

3. React Profiler:

import { Profiler } from 'react';

<Profiler id="App" onRender={logRenderTime}>
  <App />
</Profiler>

Tracks: Component render durations, re-render counts.

4. Performance API:

performance.mark('route-calc-start');
// ... pathfinding code
performance.mark('route-calc-end');
performance.measure('route-calc', 'route-calc-start', 'route-calc-end');

const measures = performance.getEntriesByType('measure');
console.log(`Route calculation: ${measures[0].duration}ms`);

Before/After Scorecard

Metric Before (Aug 2025) After (Nov 2025) Improvement
Bundle size 2.8 MB 680 KB 76% ↓
Load time (desktop) 8.2s 0.8s 90% ↓
Load time (mobile) 15.4s 2.1s 86% ↓
Lighthouse score 32 94 +62
100-hop route 12.5s 0.15s 98.8% ↓
Hover latency 300ms 8ms 97.3% ↓
Mobile FPS (pan) 18 FPS 58 FPS 3.2× ↑

Lessons Learned

1. Measure First, Optimize Later

Mistake: We initially optimized "gut feeling" bottlenecks (e.g., React re-renders) before profiling.

Reality: Biggest gains came from spatial indexing (not React), which we discovered via performance.mark().

Lesson: Always profile before optimizing.

2. Bundle Size Matters More Than You Think

Observation: Reducing bundle from 2.8 MB → 680 KB cut bounce rate from 40% → 12%.

Why: Mobile users on slow connections won't wait 15 seconds.

Lesson: Every 100 KB saved = fewer bounces.

3. Web Workers Aren't Free

Mistake: We moved all computation to workers initially, including small calculations (<10ms).

Reality: Worker message overhead (~2-5ms) made small tasks slower.

Lesson: Use workers only for tasks >50ms.

4. Native APIs Beat Libraries (Usually)

Examples:

Lesson: Check if a library feature exists natively before adding a dependency.

5. Spatial Indexes Are Magic

Impact: Spatial grid + octree gave us 250× pathfinding speedup and 37× hover speedup.

Lesson: For spatial data, O(1) lookups via grids beat O(n) scans every time.

Related Posts

Performance optimization is never finished—but by measuring, experimenting, and iterating, we transformed EF-Map from a janky prototype into a fast, responsive tool that works on desktop and mobile alike!

performanceoptimizationbundle sizespatial indexingwebglthreejs