before the previous one completes, preventing race conditions and wasted bandwidth.
Implementation
Create radar.html and paste the following. Replace YOUR_AIRLABS_KEY with your actual key.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ADS-B Client Radar</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
:root { --bg: #0b0f19; --panel: rgba(11,15,25,0.88); --accent: #4a9eff; --text: #c8d6e5; }
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: var(--bg); font-family: system-ui, sans-serif; overflow: hidden; }
#viewport { width: 100vw; height: 100vh; }
#telemetry-panel {
position: fixed; top: 1rem; left: 1rem; z-index: 1000;
background: var(--panel); color: var(--text); padding: 0.75rem 1rem;
border-radius: 6px; font-size: 0.85rem; border: 1px solid rgba(74,158,255,0.25);
backdrop-filter: blur(8px); pointer-events: none;
}
#telemetry-panel span { color: var(--accent); font-weight: 600; }
</style>
</head>
<body>
<div id="telemetry-panel">
Active: <span id="active-count">0</span> |
Last Sync: <span id="last-sync">--:--:--</span> |
Next: <span id="countdown">90</span>s
</div>
<div id="viewport"></div>
<script>
class FlightRadarApp {
constructor(config) {
this.config = config;
this.markerRegistry = new Map();
this.abortController = null;
this.countdown = config.refreshInterval;
this.initMap();
this.startPolling();
}
initMap() {
const centerLat = (this.config.bounds[0] + this.config.bounds[2]) / 2;
const centerLng = (this.config.bounds[1] + this.config.bounds[3]) / 2;
this.map = L.map('viewport', {
center: [centerLat, centerLng],
zoom: this.config.zoomLevel,
zoomControl: false,
attributionControl: false
});
L.tileLayer(this.config.tileUrl, {
maxZoom: 18,
attribution: '© OpenStreetMap © CARTO'
}).addTo(this.map);
L.control.zoom({ position: 'bottomright' }).addTo(this.map);
}
async fetchTelemetry() {
if (this.abortController) this.abortController.abort();
this.abortController = new AbortController();
const params = new URLSearchParams({
api_key: this.config.apiKey,
bbox: this.config.bounds.join(','),
_fields: 'hex,lat,lng,dir,alt,speed,flight_iata,dep_iata,arr_iata,aircraft_icao'
});
try {
const response = await fetch(`${this.config.endpoint}?${params}`, {
signal: this.abortController.signal
});
const payload = await response.json();
this.processData(payload.response || []);
this.updateHUD();
} catch (err) {
if (err.name !== 'AbortError') console.warn('Telemetry fetch interrupted:', err.message);
}
}
processData(flights) {
const currentHexes = new Set();
flights.forEach(f => {
if (!f.lat || !f.lng) return;
currentHexes.add(f.hex);
if (this.markerRegistry.has(f.hex)) {
const existing = this.markerRegistry.get(f.hex);
existing.setLatLng([f.lat, f.lng]);
existing.setIcon(this.createMarkerIcon(f.dir, f.alt));
existing.setPopupContent(this.buildPopup(f));
} else {
const marker = L.marker([f.lat, f.lng], {
icon: this.createMarkerIcon(f.dir, f.alt)
}).bindPopup(this.buildPopup(f));
marker.addTo(this.map);
this.markerRegistry.set(f.hex, marker);
}
});
this.markerRegistry.forEach((marker, hex) => {
if (!currentHexes.has(hex)) {
this.map.removeLayer(marker);
this.markerRegistry.delete(hex);
}
});
}
createMarkerIcon(heading, altitude) {
let hue;
if (altitude == null) hue = 0;
else if (altitude < 3000) hue = 120;
else if (altitude < 8000) hue = 45;
else hue = 0;
const svg = `
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2L4 12H8L12 8L16 12H20L12 2Z" fill="hsl(${hue}, 80%, 60%)" transform="rotate(${heading || 0} 12 12)"/>
</svg>`;
return L.divIcon({
className: '',
iconSize: [24, 24],
iconAnchor: [12, 12],
html: svg
});
}
buildPopup(f) {
const flightId = f.flight_iata || f.hex || 'UNKNOWN';
const route = `${f.dep_iata || '?'} → ${f.arr_iata || '?'}`;
const altFt = f.alt ? Math.round(f.alt * 3.281).toLocaleString() : 'N/A';
const speedKts = f.speed ? Math.round(f.speed * 0.54) : 'N/A';
return `
<div style="font-family:system-ui;font-size:13px;min-width:160px;line-height:1.5">
<strong style="font-size:14px">${flightId}</strong><br>
${route}<br>
Type: ${f.aircraft_icao || 'N/A'}<br>
Alt: ${altFt} ft | Spd: ${speedKts} kts
</div>`;
}
updateHUD() {
document.getElementById('active-count').textContent = this.markerRegistry.size;
document.getElementById('last-sync').textContent = new Date().toLocaleTimeString();
}
startPolling() {
this.fetchTelemetry();
setInterval(() => {
this.countdown--;
document.getElementById('countdown').textContent = this.countdown;
if (this.countdown <= 0) {
this.countdown = this.config.refreshInterval;
this.fetchTelemetry();
}
}, 1000);
}
}
const radar = new FlightRadarApp({
apiKey: 'YOUR_AIRLABS_KEY',
endpoint: 'https://airlabs.co/api/v9/flights',
bounds: [44, 2, 52, 20], // [south, west, north, east]
zoomLevel: 6,
refreshInterval: 90,
tileUrl: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
});
</script>
</body>
</html>
Why This Structure Works
- Class-based encapsulation isolates state (
markerRegistry, abortController) from the global scope, preventing namespace collisions and making the code testable.
Set-based diffing ensures stale markers are removed efficiently. Instead of clearing the entire layer group and re-adding everything, we only remove hex codes that no longer appear in the payload. This reduces DOM thrashing and preserves popup state for active flights.
- Dynamic SVG icons replace emoji characters, providing consistent rendering across operating systems and browsers. The
hsl() color mapping aligns with the altitude thresholds while keeping the markup lightweight.
AbortController prevents overlapping requests. If the network is slow and a new polling cycle triggers before the previous fetch resolves, the stale request is cancelled immediately, freeing bandwidth and avoiding race conditions.
Pitfall Guide
1. API Key Exposure in Client-Side Code
Explanation: Embedding the AirLabs key directly in HTML/JS makes it visible to anyone inspecting network traffic or source code.
Fix: For personal projects, this is acceptable. For production, route requests through a lightweight edge function (Cloudflare Workers, Vercel Edge, or AWS Lambda@Edge) that injects the key server-side and returns sanitized JSON.
2. Marker Memory Leaks from Improper Cleanup
Explanation: Calling map.removeLayer() without tracking which markers belong to which hex codes leads to orphaned DOM nodes and gradual performance degradation.
Fix: Maintain a Map or Object keyed by unique identifiers (hex). Diff incoming payloads against the registry and explicitly remove layers for missing keys.
3. Rate Limit Violations on Free Tiers
Explanation: AirLabs free tier allows 1,000 requests/day. Polling every 30 seconds generates 2,880 requests, triggering 429 Too Many Requests errors.
Fix: Adjust refreshInterval to 90 seconds (960 req/day) or implement client-side caching that skips fetches when the bounding box hasn't changed and the previous response is still fresh.
4. Coordinate Precision Mismatch
Explanation: Some APIs return coordinates with insufficient decimal places, causing markers to "snap" to grid lines instead of rendering smooth movement.
Fix: Verify the API returns at least 5–6 decimal places. If not, apply a lightweight interpolation algorithm or request higher-precision fields if the provider supports them.
5. UI Blocking During Large Dataset Renders
Explanation: Adding 1,000+ markers synchronously in a loop can freeze the main thread, causing map panning/zooming to stutter.
Fix: Use requestAnimationFrame to batch marker creation, or leverage Leaflet's L.layerGroup to add markers in chunks. For extreme scale, switch to canvas-based rendering (leaflet.canvas plugin).
6. Ignoring Network Degradation
Explanation: Assuming fetch() always succeeds leads to silent failures when the user loses connectivity or the API experiences downtime.
Fix: Implement exponential backoff for retries, display a fallback UI state, and cache the last successful payload to show stale data with a "Last updated X minutes ago" indicator.
7. Hardcoded Bounding Boxes
Explanation: Static coordinates limit the radar to a single region and require code changes to switch locations.
Fix: Expose a simple UI control or URL parameter (?bbox=...) that updates this.config.bounds and triggers a fresh fetch. Validate coordinates to prevent inverted ranges (south > north).
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Personal demo / portfolio | Direct client-side fetch with free tier key | Zero infrastructure, fastest iteration | $0 |
| Internal team dashboard | Edge proxy + client polling | Hides API key, enables caching, stays within free tier | $0–$5/mo (edge compute) |
| Public-facing product | Backend aggregator + WebSocket push | Handles high concurrency, enforces auth, scales independently | $20–$100/mo (compute + egress) |
| Low-bandwidth environments | Polling + local cache + stale-while-revalidate | Reduces network calls, maintains UX during outages | $0 |
Configuration Template
const RADAR_CONFIG = {
apiKey: process.env.AIRLABS_KEY || 'YOUR_KEY_HERE',
endpoint: 'https://airlabs.co/api/v9/flights',
bounds: [44, 2, 52, 20], // [south, west, north, east]
zoomLevel: 6,
refreshInterval: 90, // seconds (aligns with free tier limits)
tileUrl: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
fields: 'hex,lat,lng,dir,alt,speed,flight_iata,dep_iata,arr_iata,aircraft_icao',
retry: {
maxAttempts: 3,
baseDelay: 2000,
backoffFactor: 1.5
}
};
Quick Start Guide
- Generate API Key: Register at AirLabs, copy the free tier key, and paste it into
RADAR_CONFIG.apiKey.
- Define Region: Adjust
bounds using [south, west, north, east] coordinates. Use a mapping tool to extract your target area.
- Serve Locally: Run
npx serve . or open radar.html directly in a modern browser. Verify the HUD updates and markers render.
- Deploy: Push to GitHub Pages, Netlify, or Vercel. No build step required. The static file handles all logic client-side.