Skip to main content

Query Optimization

Optimize spatial query performance by choosing the right execution mode, precision level, and query patterns.

Execution Modes

Non-Paginated (pageSize = null)

All cell queries execute in parallel using Task.WhenAll:

  • Latency: ~50ms regardless of cell count (single query time)
  • Memory: All results loaded into memory
  • Best for: Small to medium result sets (< 1000 items)
var result = await table.SpatialQueryAsync(
center: center,
radiusKilometers: 5,
queryBuilder: (query, cell, pagination) => query
.Where<Store>(x => x.PartitionKey == "STORE" && x.Location == cell)
.Paginate(pagination),
pageSize: null // Non-paginated — fastest
);

Paginated (pageSize > 0)

Cells are queried sequentially in spiral order (closest to farthest):

  • Latency: N × ~50ms (where N = cells queried for this page)
  • Memory: One page in memory at a time
  • Best for: Large result sets, infinite scroll, API pagination
var result = await table.SpatialQueryAsync(
center: center,
radiusKilometers: 50,
queryBuilder: (query, cell, pagination) => query
.Where<Store>(x => x.PartitionKey == "STORE" && x.Location == cell)
.Paginate(pagination),
pageSize: 100 // Paginated — memory efficient
);

Spiral Ordering

Paginated queries use spiral ordering — cells are queried from closest to farthest from the search center. This ensures:

  • Most relevant results (closest) appear in first pages
  • Users typically only view first 1-2 pages
  • Early termination saves DynamoDB costs
  • Results are roughly sorted by distance

Precision Selection

Rule of thumb: Cell size should be 20-50% of your search radius.

Optimal cell size ≈ radius / 3

Cell Count Formula

cellCount ≈ π × (radius / cellSize)²
Search RadiusS2 LevelH3 ResolutionCell Count
1-5 km16-188-1010-50
5-10 km14-167-820-100
10-50 km12-145-750-200
50-100 km10-124-5100-300

Query Explosion Warning

danger

Using too high a precision for a large radius creates thousands of cells, making queries extremely slow or incomplete.

Bad: 10km radius with S2 Level 18 (~400m cells) = ~2,500 cells

Good: 10km radius with S2 Level 14 (~6km cells) = ~11 cells

// ❌ BAD: Too many cells for this radius
[DynamoDbAttribute("location", SpatialIndexType = SpatialIndexType.S2, S2Level = 18)]

// ✅ GOOD: Appropriate precision for 10km queries
[DynamoDbAttribute("location", SpatialIndexType = SpatialIndexType.S2, S2Level = 14)]

MaxCells Limit

The default maxCells limit is 100. When exceeded:

  • Only the first 100 cells are queried
  • Results are incomplete — no error is thrown
  • Monitor result.TotalCellsQueried to detect this
var result = await table.SpatialQueryAsync(...);

if (result.TotalCellsQueried >= 100)
{
_logger.LogWarning("Hit maxCells limit — results may be incomplete. " +
"Consider reducing precision or radius.");
}

Multiple Precision Levels

For applications with varying search radii, store data at multiple precisions:

public partial class Store
{
// Low precision for large area queries (50-100km)
[DynamoDbAttribute("location_region", SpatialIndexType = SpatialIndexType.S2, S2Level = 12)]
public GeoLocation LocationRegion => Location;

// Medium precision for city queries (10-50km)
[DynamoDbAttribute("location_city", SpatialIndexType = SpatialIndexType.S2, S2Level = 14)]
public GeoLocation LocationCity => Location;

// High precision for local queries (1-10km)
[DynamoDbAttribute("location_local", SpatialIndexType = SpatialIndexType.S2, S2Level = 16)]
public GeoLocation LocationLocal => Location;

[DynamoDbAttribute("location")]
public GeoLocation Location { get; set; }
}

// Choose attribute based on radius
var attributeName = radiusKm switch
{
<= 10 => "location_local",
<= 50 => "location_city",
_ => "location_region"
};

Cost Optimization

DynamoDB Read Capacity

Each cell query consumes RCUs. Total cost:

Total RCUs = Cell Count × RCUs per Query

Example: 10km radius with S2 Level 16 = 140 cells × 5 RCUs = 700 RCUs per search.

Strategies

  1. Use lower precision — Fewer cells = fewer queries = lower cost
  2. Use eventual consistency — Half the RCU cost vs strong consistency
  3. Implement caching — Cache popular search areas
  4. Limit search radius — Smaller radius = fewer cells
// Use eventual consistency for spatial queries
var result = await table.SpatialQueryAsync(
center: center,
radiusKilometers: 10,
queryBuilder: (query, cell, pagination) => query
.Where<Store>(x => x.PartitionKey == "STORE" && x.Location == cell)
.WithConsistentRead(false)
.Paginate(pagination),
pageSize: null
);

Monitoring

Track these metrics in production:

var stopwatch = Stopwatch.StartNew();
var result = await table.SpatialQueryAsync(...);
stopwatch.Stop();

_logger.LogInformation(
"Spatial query: Radius={Radius}km, Cells={Cells}, " +
"Scanned={Scanned}, Returned={Returned}, Latency={Latency}ms",
radiusKm,
result.TotalCellsQueried,
result.TotalItemsScanned,
result.Items.Count,
stopwatch.ElapsedMilliseconds);

Alert thresholds:

  • TotalCellsQueried > 100 — Consider reducing precision
  • Query efficiency (Items / Scanned) < 50% — Precision may be too low
  • Latency > 500ms — Review execution mode and precision

Quick Reference

ScenarioModePrecisionExpected Performance
Small area, need all resultsNon-paginatedHigh~50ms
Large area, need all resultsNon-paginatedLow~50ms
Small area, paginated APIPaginatedHigh~100-200ms first page
Large area, paginated APIPaginatedLow~100-200ms first page

When in doubt: Start with S2 Level 16 (non-paginated) for radius < 10km, and S2 Level 14 (paginated) for larger areas. Adjust based on monitoring.

See Also