Performance Guide
Optimize Sightline for large datasets and fast load times.
Performance Overview
Sightline is optimized for most use cases out of the box. This guide helps you scale to larger datasets and improve performance for specific scenarios.
Recommended Limits
| Configuration | Recommended Limit | Notes |
|---|---|---|
| React clustering | ~5,000 incidents | Custom marker rendering |
| Mapbox clustering | ~50,000 incidents | Native performance |
| With pagination | 100,000+ incidents | Viewport-based loading |
Clustering Optimization
Choose the Right Clustering Mode
Best for: Large datasets (10,000+ incidents), performance priority
map: {
clustering: {
mode: 'mapbox',
radius: 50,
maxZoom: 14,
mapboxStyle: {
colors: {
small: '#51bbd6', // < 10 points
medium: '#f1f075', // 10-100 points
large: '#f28cb1', // > 100 points
},
sizes: {
small: 20,
medium: 30,
large: 40,
},
},
},
}Pros:
- GPU-accelerated rendering
- Handles 50,000+ points smoothly
- No React re-renders for clusters
Cons:
- Limited styling customization
- Can't use custom React components for clusters
Best for: Custom renderers, medium datasets (< 5,000 incidents)
map: {
clustering: {
mode: 'react',
radius: 50,
maxZoom: 14,
},
}Pros:
- Full customization with React components
- Custom cluster renderers
- Easier debugging
Cons:
- Performance degrades with large datasets
- More React re-renders
Best for: Small datasets (< 500 incidents), all markers visible
map: {
clustering: {
mode: 'none',
},
}Pros:
- Every incident visible
- No clustering logic overhead
Cons:
- Performance issues with many markers
- Visual clutter
Optimize Cluster Radius
Larger radius = fewer clusters = better performance:
// Fewer clusters, better performance
clustering: { radius: 75 }
// More clusters, more detail
clustering: { radius: 30 }Set Appropriate maxZoom
Stop clustering at higher zoom levels:
clustering: {
maxZoom: 12, // Clusters until zoom 12, then individual markers
}Data Loading Optimization
Adapter Caching
API and Supabase adapters include built-in caching to reduce network requests and improve performance. Caching is enabled by default with a 5-minute TTL.
dataSources: [
{
id: 'my-api',
name: 'My API',
adapter: 'api',
adapterConfig: {
endpoint: 'https://api.example.com',
// Caching options
enableCache: true, // Enable caching (default: true)
cacheTtlMs: 5 * 60 * 1000, // Cache TTL in ms (default: 5 minutes)
},
enabled: true,
},
],Cache Invalidation: Adapters provide a clearCache() method to manually invalidate cached data when you know the source has changed.
// Clear cache when data is updated
adapter.clearCache();When to adjust caching:
| Scenario | Recommendation |
|---|---|
| Real-time data | Disable cache or use short TTL (30s) |
| Static datasets | Long TTL (1 hour) or use static adapter |
| Frequent updates | Medium TTL (1-5 minutes) |
| Rate-limited APIs | Enable cache with longer TTL |
Pagination
For large datasets, load data in pages:
controls: {
incidentFeed: {
initialCount: 20, // Load 20 initially
pageSize: 20, // Load 20 more on scroll
},
}Viewport-Based Loading
Load only incidents visible in the current map bounds:
export class ViewportAdapter implements DataAdapter {
async getIncidents(filters?: IncidentFilters): Promise<Incident[]> {
const bounds = filters?.bounds;
if (bounds) {
// Only fetch incidents within viewport
const response = await fetch(
`/api/incidents?` +
`minLat=${bounds.south}&maxLat=${bounds.north}` +
`&minLng=${bounds.west}&maxLng=${bounds.east}`
);
return response.json();
}
// Fallback: load all
return this.getAllIncidents();
}
}Debounce Map Events
Prevent excessive data fetching on map movement:
import { useDebouncedCallback } from 'use-debounce';
const handleBoundsChange = useDebouncedCallback(
(bounds: MapBounds) => {
refresh({ bounds });
},
300 // 300ms debounce
);React Optimization
Memoize Expensive Computations
// ❌ Recalculates on every render
const filteredIncidents = incidents.filter(applyFilters);
// ✅ Only recalculates when dependencies change
const filteredIncidents = useMemo(
() => incidents.filter(applyFilters),
[incidents, filters]
);Stabilize Callbacks
// ❌ New function on every render
<MapEngine onIncidentSelect={(incident) => selectIncident(incident)} />
// ✅ Stable reference
const handleIncidentSelect = useCallback(
(incident: IncidentDisplay | null) => selectIncident(incident),
[selectIncident]
);
<MapEngine onIncidentSelect={handleIncidentSelect} />Virtualize Long Lists
For the incident feed with many items:
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualizedFeed({ incidents }: { incidents: Incident[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: incidents.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 80, // Estimated item height
overscan: 5,
});
return (
<div ref={parentRef} className="h-full overflow-auto">
<div
style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}
>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
>
<FeedItem incident={incidents[virtualItem.index]} />
</div>
))}
</div>
</div>
);
}Lazy Load Components
import { lazy, Suspense } from 'react';
const FullViewModal = lazy(() => import('@/examples/renderers/full-view-modal'));
function Popup({ incident, onViewDetails }: PopupProps) {
const [showModal, setShowModal] = useState(false);
return (
<>
<button onClick={() => setShowModal(true)}>View Details</button>
{showModal && (
<Suspense fallback={<LoadingSpinner />}>
<FullViewModal incident={incident} onClose={() => setShowModal(false)} />
</Suspense>
)}
</>
);
}Bundle Optimization
Analyze Bundle Size
# Build with bundle analyzer
ANALYZE=true pnpm buildDynamic Imports
Split large dependencies:
// ❌ Loads entire library upfront
import { Chart } from 'chart.js';
// ✅ Load only when needed
const loadChart = async () => {
const { Chart } = await import('chart.js');
return Chart;
};Tree Shaking
Import only what you need:
// ❌ Imports entire library
import * as Icons from 'lucide-react';
// ✅ Import specific icons
import { MapPin, Calendar, X } from 'lucide-react';Image Optimization
Use Next.js Image
import Image from 'next/image';
<Image
src={incident.media[0].url}
alt={incident.media[0].caption || 'Incident media'}
width={400}
height={300}
placeholder="blur"
blurDataURL={incident.media[0].blurHash}
/>Generate Thumbnails
Store and use thumbnails for previews:
{
"media": [
{
"type": "image",
"url": "/uploads/full-size.jpg",
"thumbnailUrl": "/uploads/thumbnail.jpg",
"width": 1920,
"height": 1080
}
]
}Lazy Load Media
<img
src={thumbnailUrl}
alt={caption}
loading="lazy"
decoding="async"
/>Caching Strategies
Static Data Caching
For static JSON files, leverage browser caching:
const nextConfig = {
async headers() {
return [
{
source: '/data/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=3600, stale-while-revalidate=86400',
},
],
},
];
},
};API Response Caching
// Cache API responses in memory
const cache = new Map<string, { data: Incident[]; timestamp: number }>();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
async function getCachedIncidents(url: string): Promise<Incident[]> {
const cached = cache.get(url);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.data;
}
const response = await fetch(url);
const data = await response.json();
cache.set(url, { data, timestamp: Date.now() });
return data;
}Service Worker (PWA)
For offline support and aggressive caching:
const CACHE_NAME = 'sightline-v1';
const STATIC_ASSETS = [
'/',
'/data/incidents.json',
// ... other assets
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
);
});
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((response) => {
return response || fetch(event.request);
})
);
});Database Optimization (Supabase)
Add Indexes
-- Index for common queries
create index idx_incidents_date on incidents (temporal->>'date');
create index idx_incidents_country on incidents (location->>'country');
create index idx_incidents_status on incidents (status);
-- Spatial index for geographic queries
create index idx_incidents_location on incidents using gist (
st_makepoint(
(location->>'longitude')::float,
(location->>'latitude')::float
)
);Limit Query Results
const { data } = await supabase
.from('incidents')
.select('*')
.eq('status', 'published')
.limit(1000) // Always set a reasonable limit
.order('temporal->date', { ascending: false });Use Pagination
const PAGE_SIZE = 100;
async function fetchPage(page: number) {
const { data, count } = await supabase
.from('incidents')
.select('*', { count: 'exact' })
.range(page * PAGE_SIZE, (page + 1) * PAGE_SIZE - 1);
return { data, totalPages: Math.ceil((count || 0) / PAGE_SIZE) };
}Monitoring Performance
Core Web Vitals
Track key metrics:
| Metric | Target | Description |
|---|---|---|
| LCP | < 2.5s | Largest Contentful Paint |
| FID | < 100ms | First Input Delay |
| CLS | < 0.1 | Cumulative Layout Shift |
React DevTools Profiler
- Open React DevTools
- Go to "Profiler" tab
- Record a session
- Identify slow components
Lighthouse
# Run Lighthouse audit
npx lighthouse https://your-site.com --viewPerformance Checklist
- Use Mapbox clustering for 5,000+ incidents
- Implement pagination for large datasets
- Memoize filtered incidents calculation
- Use stable callback references
- Virtualize long lists
- Lazy load heavy components
- Optimize images with Next.js Image
- Add database indexes
- Set up appropriate caching
- Monitor Core Web Vitals