Makuhari Development Corporation
10 min read, 1804 words, last updated: 2026/1/16
TwitterLinkedInFacebookEmail

Designing Multi-Layer Cache Architecture for GIS Web Applications: A Deep Dive

Introduction

Geographic Information Systems (GIS) web applications face unique challenges when it comes to performance optimization. Unlike traditional web applications, GIS systems must handle massive spatial datasets while delivering computationally expensive operations like isochrone (equal-time) calculations in real-time. This creates a perfect storm: large data volumes that strain network resources, combined with complex algorithms that consume significant computational power.

In this deep dive, we'll explore how to design a sophisticated multi-layer caching architecture specifically tailored for GIS applications that use Valhalla for pedestrian and vehicle routing, and R5 for public transit analysis. We'll examine how to balance data distribution across client and server tiers while maintaining accuracy and ensuring reproducible results for formal reporting.

Background and Context

The GIS Performance Challenge

Modern GIS web applications typically exhibit several characteristics that make caching strategy critical:

  • Large base datasets: Point-of-interest data, administrative boundaries, and road networks can easily reach gigabytes in size
  • Expensive computations: Isochrone calculations require graph traversal algorithms that can take seconds to minutes for complex queries
  • Small result sets: Despite the computational complexity, final results (like polygon boundaries) are often only a few kilobytes
  • Geographic clustering: Users tend to focus on specific regions, creating natural locality patterns
  • Update frequency: Base data typically updates monthly or yearly, not requiring real-time synchronization

Technical Foundation

Our reference architecture assumes:

  • Walking/Driving routes: Valhalla engine with OpenStreetMap road networks
  • Public transit: R5 engine with OSM + GTFS data
  • Update pattern: Monthly or yearly data refreshes without real-time requirements
  • Usage pattern: Geographically concentrated user activity

Core Concepts

Multi-Layer Caching Philosophy

The key insight for GIS caching is to distribute data based on size and computational cost characteristics:

  1. Large, stable data → Push toward users (client-side, CDN)
  2. Small, expensive results → Centralize (server-side caching)
  3. Progressive computation → Coarse-first, fine later

Six-Tier Architecture

┌─────────────────────┐
│   Client Cache      │  ← Large, stable, reusable data
├─────────────────────┤
│   CDN/Edge Cache    │  ← Static tiles, immutable resources
├─────────────────────┤
│   API Gateway       │  ← Request routing, authentication
├─────────────────────┤
│   Hot Cache (Redis) │  ← Frequently accessed small results
├─────────────────────┤
│ Persistent Cache    │  ← Expensive computation results
├─────────────────────┤
│ Compute Layer       │  ← Valhalla + R5 engines
└─────────────────────┘

Cache Key Versioning Strategy

All cache keys must incorporate versioning to prevent data inconsistencies:

iso:{engine}:{algo_v}:{dataset_v}:{region}:{profile}:{minutes}:{precision}:{params_hash}

Where:

  • engine: valhalla, r5
  • algo_v: Algorithm version for reproducible results
  • dataset_v: Data version (e.g., 2026-01)
  • region: Geographic area identifier
  • profile: walk, drive, transit
  • minutes: Time threshold
  • precision: coarse, final_low, final_high
  • params_hash: Hash of additional parameters

Analysis: Layer-by-Layer Implementation

Layer 1: Client-Side Caching

Purpose: Store voluminous, stable, frequently reused geographic data

Implementation Options:

Tile-Based Caching (Recommended):

// Example IndexedDB structure for tile cache
const tileStore = {
  z: 12,           // Zoom level
  x: 2048,         // Tile X coordinate
  y: 1024,         // Tile Y coordinate
  version: "2026-01",
  data: blob,      // Vector or raster tile data
  timestamp: Date.now(),
  region: "city_tokyo"
};

Dataset Packages (Optional):

  • MBTiles/PMTiles for offline capability
  • FlatGeobuf with spatial indexing
  • Region-specific packages for frequent areas

Storage Management:

class GISCacheManager {
  constructor(maxSizeMB = 500) {
    this.maxSize = maxSizeMB * 1024 * 1024;
    this.db = new IndexedDB('gis_cache');
  }
  
  async evictLRU(regionId) {
    // Remove least recently used tiles for region
    const tiles = await this.db.getByRegion(regionId);
    tiles.sort((a, b) => a.lastAccessed - b.lastAccessed);
    
    let freedSpace = 0;
    for (const tile of tiles) {
      if (freedSpace > this.maxSize * 0.2) break;
      await this.db.delete(tile.id);
      freedSpace += tile.size;
    }
  }
}

Layer 2: CDN/Edge Caching

Purpose: Maximize static resource hit rates with geographic distribution

Strategy:

  • Use immutable URLs with content hashing
  • Implement proper ETags for validation
  • Configure aggressive caching for versioned resources
Cache-Control: public, max-age=31536000, immutable
ETag: "sha256-abc123..."

Layer 3: Server Hot Cache (Redis)

Purpose: Cache frequently accessed, moderately expensive query results

Content Types:

  • Coarse isochrone results (short TTL)
  • Intermediate computation results
  • Popular query result sets
class HotCache:
    def __init__(self):
        self.redis = Redis(host='localhost', port=6379)
        self.default_ttl = 3600  # 1 hour
    
    def get_coarse_isochrone(self, key):
        data = self.redis.get(f"coarse:{key}")
        return json.loads(data) if data else None
    
    def set_coarse_isochrone(self, key, result, ttl=None):
        ttl = ttl or self.default_ttl
        self.redis.setex(
            f"coarse:{key}", 
            ttl, 
            json.dumps(result)
        )

Layer 4: Persistent Result Cache

Purpose: Long-term storage of expensive computation results

Implementation: Object storage (S3/GCS) + metadata in PostGIS

CREATE TABLE cached_isochrones (
  id UUID PRIMARY KEY,
  cache_key TEXT UNIQUE NOT NULL,
  region_id TEXT NOT NULL,
  profile TEXT NOT NULL,
  minutes INTEGER NOT NULL,
  precision TEXT NOT NULL,
  result_url TEXT NOT NULL,
  polygon GEOMETRY(POLYGON, 4326),
  created_at TIMESTAMP DEFAULT NOW(),
  dataset_version TEXT NOT NULL,
  algorithm_version TEXT NOT NULL
);
 
CREATE INDEX idx_isochrone_lookup 
ON cached_isochrones(region_id, profile, minutes, precision, dataset_version);

Layer 5: Job Orchestration System

Purpose: Manage progressive computation and prevent cache stampede

from enum import Enum
 
class JobStatus(Enum):
    PENDING = "pending"
    COARSE_READY = "coarse_ready"
    FINAL_READY = "final_ready"
    FAILED = "failed"
 
class IsochroneJob:
    def __init__(self, params):
        self.id = str(uuid.uuid4())
        self.params = params
        self.status = JobStatus.PENDING
        self.coarse_result = None
        self.final_result = None
        self.created_at = datetime.now()
    
    async def compute_coarse(self):
        # Fast computation with simplified parameters
        result = await valhalla_client.isochrone(
            **self.params,
            precision='coarse',
            simplify_tolerance=100  # More aggressive simplification
        )
        self.coarse_result = result
        self.status = JobStatus.COARSE_READY
        return result
    
    async def compute_final(self):
        # High-precision computation
        result = await valhalla_client.isochrone(
            **self.params,
            precision='high',
            simplify_tolerance=10
        )
        self.final_result = result
        self.status = JobStatus.FINAL_READY
        await self.save_to_persistent_cache()
        return result

Progressive Computation Strategy

Coarse vs. Fine Computation Parameters

For Valhalla (Walking/Driving):

coarse_config:
  edge_walk_on_rise: 0.6      # vs 0.9 for fine
  shape_match: "map_snap"     # vs "edge_walk" for fine
  filters:
    min_road_class: 3         # Skip residential streets
  
fine_config:
  edge_walk_on_rise: 0.9
  shape_match: "edge_walk"
  use_timestamps: true
  filters:
    min_road_class: 0         # Include all road types

For R5 (Transit):

coarse_config:
  max_walks: 3               # Limit transfer complexity
  walk_speed: 1.3            # Slightly faster assumption
  max_trip_duration: 7200    # 2 hours max
  
fine_config:
  max_walks: 4
  walk_speed: 1.2            # More conservative
  max_trip_duration: 10800   # 3 hours max
  detailed_itineraries: true

Ensuring Result Consistency

The critical requirement is that fine results should be monotonic refinements of coarse results:

def validate_refinement(coarse_polygon, fine_polygon):
    """Ensure fine result doesn't dramatically differ from coarse"""
    
    coarse_area = coarse_polygon.area
    fine_area = fine_polygon.area
    
    # Fine result should be within 20% of coarse area
    area_ratio = abs(fine_area - coarse_area) / coarse_area
    assert area_ratio < 0.2, "Refinement changed area too dramatically"
    
    # Majority of coarse polygon should overlap with fine
    intersection = coarse_polygon.intersection(fine_polygon)
    overlap_ratio = intersection.area / coarse_area
    assert overlap_ratio > 0.7, "Insufficient overlap between coarse and fine"

Implementation Considerations

Cache Stampede Prevention

When multiple users request the same expensive computation:

import asyncio
from collections import defaultdict
 
class RequestCoalescer:
    def __init__(self):
        self.pending_jobs = defaultdict(list)
        self.job_locks = defaultdict(asyncio.Lock)
    
    async def get_or_create_job(self, cache_key, job_factory):
        async with self.job_locks[cache_key]:
            # Check if job already exists
            existing_job = await self.get_existing_job(cache_key)
            if existing_job:
                return existing_job
            
            # Create new job if none exists
            job = await job_factory()
            self.pending_jobs[cache_key].append(job)
            
            # Start computation
            asyncio.create_task(self.execute_job(job, cache_key))
            return job

Multi-Resolution Result Storage

Store computation results at multiple detail levels:

class MultiResolutionCache:
    RESOLUTIONS = {
        'low': {'tolerance': 200, 'max_points': 50},
        'mid': {'tolerance': 100, 'max_points': 100}, 
        'high': {'tolerance': 50, 'max_points': 200}
    }
    
    def store_result(self, base_key, polygon):
        """Store polygon at multiple resolutions"""
        for resolution, params in self.RESOLUTIONS.items():
            simplified = polygon.simplify(
                tolerance=params['tolerance'],
                max_points=params['max_points']
            )
            
            cache_key = f"{base_key}:{resolution}"
            self.cache.set(cache_key, simplified.to_geojson())

Data Version Management

Handle dataset updates gracefully:

class VersionManager:
    def __init__(self):
        self.current_version = None
        self.manifest_cache = TTLCache(maxsize=100, ttl=3600)
    
    async def get_manifest(self, region_id):
        """Get current data version and available resources"""
        if region_id in self.manifest_cache:
            return self.manifest_cache[region_id]
        
        manifest = {
            'version': '2026-01',
            'region_id': region_id,
            'packages': [
                {
                    'type': 'mbtiles',
                    'url': f'/data/{region_id}/tiles.mbtiles',
                    'size_mb': 45,
                    'hash': 'sha256:abc123...'
                }
            ],
            'tile_bounds': {
                'min_zoom': 10,
                'max_zoom': 14,
                'bbox': [139.69, 35.65, 139.77, 35.70]
            }
        }
        
        self.manifest_cache[region_id] = manifest
        return manifest

Implications and Best Practices

For System Architecture

  1. Embrace Geographic Locality: Design your caching strategy around the reality that users cluster geographically. This allows for effective precomputation and regional optimization.

  2. Version Everything: GIS applications require reproducible results for reports and analysis. Every cache key must incorporate dataset and algorithm versions.

  3. Plan for Cache Migration: When data or algorithms update, you need graceful migration strategies that don't break existing user workflows.

For Performance Optimization

  1. Optimize for the Common Case: Most users will request common parameters (15/30/45 minute isochrones for major cities). Precompute these aggressively.

  2. Progressive Enhancement: Always return something immediately, even if it's approximate. Users prefer fast approximate results to slow perfect ones.

  3. Monitor Cache Hit Rates: Track hit rates across all cache layers to identify optimization opportunities and capacity planning needs.

For Data Management

  1. Separate Concerns: Keep base geographic data separate from computed results. They have different update cycles and access patterns.

  2. Plan for Scale: As your service grows, you'll need to shard data geographically and potentially deploy regional compute clusters.

  3. Audit Trail: For formal reports, maintain complete provenance of data versions, algorithm versions, and parameters used.

Advanced Considerations

Precomputation Strategy

Implement intelligent precomputation based on usage patterns:

class PrecomputeManager:
    def __init__(self):
        self.popularity_tracker = PopularityTracker()
        self.compute_queue = AsyncQueue()
    
    async def schedule_precompute(self):
        """Run nightly precomputation for popular queries"""
        popular_queries = await self.popularity_tracker.get_top_queries(
            timeframe_hours=24,
            min_requests=5
        )
        
        for query in popular_queries:
            if not await self.is_cached(query):
                await self.compute_queue.enqueue(
                    job_type='precompute',
                    priority='low',
                    params=query
                )

Cross-Engine Result Sharing

When possible, share intermediate results between Valhalla and R5:

class CrossEngineCache:
    def get_walkable_areas(self, origin, minutes):
        """Get walkable polygon that both engines can use"""
        key = f"walk_polygon:{origin}:{minutes}"
        
        cached = self.cache.get(key)
        if cached:
            return cached
        
        # Compute once with Valhalla, reuse for R5 transit stops
        polygon = valhalla.isochrone(
            origin=origin,
            costing='pedestrian',
            minutes=minutes
        )
        
        self.cache.set(key, polygon, ttl=3600*24)  # 24 hour cache
        return polygon

Conclusion

Designing an effective multi-layer cache architecture for GIS web applications requires balancing multiple competing concerns: data volume, computational cost, user experience, and result accuracy. The key insights are:

  1. Distribute data based on characteristics: Large, stable data belongs near users; small, expensive results belong on servers
  2. Embrace progressive computation: Users prefer fast approximate results followed by precise refinements
  3. Version everything: GIS applications require reproducible, auditable results
  4. Leverage geographic locality: Users cluster geographically, making precomputation highly effective

By implementing the six-tier architecture outlined here—spanning client storage through persistent result caching—GIS applications can achieve the performance characteristics needed for responsive user experiences while maintaining the accuracy required for professional analysis and reporting.

The combination of Valhalla for vehicle/pedestrian routing and R5 for transit analysis, when properly cached, can deliver sub-second response times for the majority of user queries while ensuring that complex, high-precision computations are available when needed for formal outputs.

This architecture scales naturally as user bases grow, data volumes increase, and computational requirements evolve, making it a solid foundation for production GIS web applications.

Makuhari Development Corporation
法人番号: 6040001134259
ご利用にあたって
個人情報保護方針
個人情報取扱に関する同意事項
お問い合わせ
Copyright© Makuhari Development Corporation. All Rights Reserved.