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:
- Desktop (Chrome): 8.2 seconds (Lighthouse score: 32)
 - Mobile (Android): 15.4 seconds (Lighthouse score: 18)
 - Lighthouse warnings: "Main thread blocked for 6.2s"
 
Bundle size:
- Total JS: 2.8 MB (uncompressed)
 - Gzipped: 890 KB
 - Largest chunk: 
main.js(1.9 MB) 
Runtime performance:
- Hover lag: 300-500ms to show system label
 - Route calculation: 12 seconds (100-hop route, Jita → Amarr)
 - Pan/zoom: Janky (~18 FPS on mobile)
 
User impact:
- 40% bounce rate (users left before map loaded)
 - Mobile: "Browser not responding" dialogs
 - Route sharing failed (bundle too large for serverless platforms)
 
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:
- Three.js bundle: 600 KB → 180 KB (70% reduction)
 - Total bundle: 2.8 MB → 1.6 MB (43% reduction)
 
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:
- Initial bundle: 1.6 MB → 980 KB (39% reduction)
 - Panel chunks: Loaded on-demand (50-80 KB each)
 
Solution 3: Remove Unused Dependencies
Audit (npm):
npx depcheck
Found:
lodash(120 KB): Only used for_.debounce→ Replaced with native JSmoment.js(230 KB): Replaced with nativeDate+Intl.DateTimeFormataxios(90 KB): Replaced with nativefetch
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:
- Bundle: 980 KB → 680 KB (31% reduction from initial 2.8 MB → 76% total reduction)
 
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:
- Geometry creation: 4.2s → 0.08s (52x speedup)
 - Main thread block: 6.2s → 2.1s (66% reduction)
 
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:
- Main thread block: 2.1s → 0.3s (85% reduction)
 - FPS during load: 0 FPS → 30 FPS (map interactive while loading)
 
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:
- Average systems per cell: ~80 (vs 8,000 total)
 getNeighborstime: 5ms → 0.02ms (250x speedup)- 100-hop route: 12.5s → 0.4s (31x speedup)
 
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:
- 100-hop route: 0.4s → 0.15s (62% faster)
 - Cache hit rate: ~85% (most systems explored multiple times)
 
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:
- Intersection tests: 8,000 → ~120 (98.5% reduction)
 - Hover latency: 300ms → 8ms (37x faster)
 
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:
- React re-renders: 45/s → 0/s during pan/zoom
 - Mobile FPS: 18 FPS → 58 FPS (3.2x improvement)
 
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:
- Query time: 120ms → 2ms (60x speedup)
 - Search responsiveness: Instant autocomplete
 
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:
- Network requests: 12 → 1 (92% reduction)
 - Load time (3G): 6s → 1.8s (70% faster)
 
Measurement-Driven Optimization
Tools We Used
1. Lighthouse (Chrome DevTools):
lighthouse https://ef-map.com --view
Metrics:
- Performance score (0-100)
 - First Contentful Paint (FCP)
 - Largest Contentful Paint (LCP)
 - Total Blocking Time (TBT)
 
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:
- Lodash 
debounce→ Native JS: -120 KB - Moment.js → Native 
Date: -230 KB - Axios → 
fetch: -90 KB 
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
- Web Workers: Keeping the UI Responsive While Calculating 100-Hop Routes - How we parallelized pathfinding
 - A* vs Dijkstra: Choosing the Right Pathfinding Algorithm - Algorithm choice impacts performance
 - Database Architecture: From Blockchain Events to Queryable Intelligence - How we optimized database queries
 
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!