Computing a travel-time matrix for 500 origins against 500 destinations means either one request that no engine will accept, or thousands of tiny requests that saturate a connection pool and starve each other. This page covers the specific mechanics of parallelizing origin-destination matrix requests with aiohttp inside the broader async route matrix automation workflow, which also covers semaphore-gated concurrency and chunk tiling at a conceptual level. It assumes the fleet automation context described in routing API automation and fleet integration — geocoded stops that need an NxN or NxM cost matrix before a solver can run.
The problem is not raw request volume; aiohttp can issue thousands of requests per second. The problem is that OSRM’s /table endpoint and Valhalla’s /sources_to_targets endpoint both enforce hard coordinate-count ceilings per request, and both degrade badly under uncontrolled concurrency — OSRM’s single-threaded Lua-free table handler queues behind itself, and Valhalla’s thread pool thrashes when the number of in-flight requests exceeds its worker count by a wide margin. Getting this right means matching three independent limits: the engine’s per-request coordinate cap, the engine’s server-side worker concurrency, and the client’s connection pool size.
When to Use This Approach
Use parallel aiohttp fan-out when the origin-destination set exceeds a single engine request’s coordinate ceiling, or when a matrix that fits in one request still takes long enough (multiple seconds) that splitting it into concurrent sub-requests reduces wall-clock time. Typical triggers:
- The combined origin and destination coordinate count exceeds OSRM’s
--max-table-size(default 8000) or Valhalla’s configuredmax_matrix_distance/max_locationsfor the costing model in use. - A single-request matrix returns in acceptable time but you are computing matrices for many depots or many time-of-day snapshots back to back, and total batch throughput — not single-matrix latency — is the bottleneck.
- The origin set changes faster than the destination set (or vice versa), so caching destination-side sub-matrices and only re-requesting the changed rows saves real request volume.
Skip this pattern when the full matrix fits comfortably under the engine’s request cap and the query is one-off — a single synchronous /table call is simpler and easier to debug. It also does not replace chunking large origin-destination matrices, which handles how chunk boundaries are chosen; this page assumes chunks already exist and focuses on the transport layer that fetches and reassembles them.
Implementation
The snippet below issues chunk requests concurrently against an OSRM /table endpoint (the same shape applies to Valhalla with a POST body swap, noted inline), gates concurrency independently from the connection pool, and writes each response directly into a pre-allocated NumPy matrix at its known offset — no post-hoc merge step required.
# requires: aiohttp, numpy (pip install aiohttp numpy)
# Python 3.9+
import asyncio
import numpy as np
import aiohttp
OSRM_BASE = "http://localhost:5000"
POOL_LIMIT = 20 # total open connections in the TCPConnector
CONCURRENCY = 12 # in-flight requests; must be <= POOL_LIMIT
REQUEST_TIMEOUT = aiohttp.ClientTimeout(total=30, connect=5, sock_read=20)
async def fetch_chunk(
session: aiohttp.ClientSession,
sem: asyncio.Semaphore,
origins: list[tuple[float, float]],
dests: list[tuple[float, float]],
row_offset: int,
col_offset: int,
out: np.ndarray,
attempt: int = 0,
) -> None:
"""Fetch one OSRM /table chunk and write durations directly into `out`."""
coords = origins + dests
coord_str = ";".join(f"{lon},{lat}" for lat, lon in coords)
sources = ";".join(str(i) for i in range(len(origins)))
destinations = ";".join(str(len(origins) + j) for j in range(len(dests)))
url = f"{OSRM_BASE}/table/v1/driving/{coord_str}"
params = {"sources": sources, "destinations": destinations, "annotations": "duration"}
async with sem:
try:
async with session.get(url, params=params, timeout=REQUEST_TIMEOUT) as resp:
resp.raise_for_status()
payload = await resp.json()
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
if attempt < 3:
await asyncio.sleep(0.5 * (2 ** attempt))
return await fetch_chunk(
session, sem, origins, dests, row_offset, col_offset, out, attempt + 1
)
raise RuntimeError(f"chunk [{row_offset}:{col_offset}] failed after retries") from exc
block = np.array(payload["durations"], dtype=np.float64)
out[row_offset:row_offset + block.shape[0], col_offset:col_offset + block.shape[1]] = block
async def build_matrix(
origin_chunks: list[list[tuple[float, float]]],
dest_chunks: list[list[tuple[float, float]]],
) -> np.ndarray:
n_rows = sum(len(c) for c in origin_chunks)
n_cols = sum(len(c) for c in dest_chunks)
matrix = np.full((n_rows, n_cols), np.nan, dtype=np.float64)
connector = aiohttp.TCPConnector(limit=POOL_LIMIT, limit_per_host=POOL_LIMIT)
sem = asyncio.Semaphore(CONCURRENCY)
async with aiohttp.ClientSession(connector=connector) as session:
tasks = []
row_offset = 0
for origins in origin_chunks:
col_offset = 0
for dests in dest_chunks:
tasks.append(
fetch_chunk(session, sem, origins, dests, row_offset, col_offset, matrix)
)
col_offset += len(dests)
row_offset += len(origins)
await asyncio.gather(*tasks)
return matrix
Valhalla variant. Swap session.get for session.post(f"{VALHALLA_BASE}/sources_to_targets", json={...}) with a body of {"sources": [{"lat": ..., "lon": ...}, ...], "targets": [...], "costing": "auto"}, and parse payload["sources_to_targets"] — a list of lists of {"time": ..., "distance": ...} dicts — into the same block-write pattern. Valhalla’s response nests time and distance per cell rather than returning two parallel arrays like OSRM, so extract with np.array([[c["time"] for c in row] for row in payload["sources_to_targets"]]).
Key Parameters and Tuning
| Parameter | Where set | Typical value | Effect |
|---|---|---|---|
TCPConnector(limit=...) |
Client, once per session | 15-30 | Hard cap on total open connections; exceeding the engine’s worker thread count wastes sockets without adding throughput |
TCPConnector(limit_per_host=...) |
Client | equal to limit for single-host engines |
Prevents per-host throttling when all chunks target the same OSRM/Valhalla instance |
asyncio.Semaphore(N) |
Client, wraps each request | ≤ connector limit |
Caps requests actually issued at once; keeping it below the connector limit avoids queuing inside the pool |
ClientTimeout(sock_read=...) |
Per-request or session default | 15-25s | Large chunks (near the coordinate ceiling) take longer per request; too low a sock_read timeout causes false-positive retries |
| Chunk cell budget | Chunking layer, feeds this page | 60-80% of --max-table-size |
Leaving headroom below the hard cap avoids 400 errors when coordinate counts vary slightly between chunks |
| Retry backoff | fetch_chunk |
exponential, base 0.5s, max 3 attempts | Absorbs transient 503s from an engine under momentary load without amplifying pressure via immediate retries |
Connector vs. semaphore, not either/or. A common mistake is setting only the TCPConnector limit and assuming it caps concurrency. It caps the connection pool, not in-flight coroutines — with no semaphore, asyncio.gather will schedule every chunk task immediately, and excess tasks block silently inside the connector waiting for a socket rather than failing or logging anything. Always pair the two, with the semaphore value at or below the connector limit.
Engine-side worker count matters more than client concurrency. OSRM’s HTTP server is typically compiled with a fixed thread pool (-t at launch, or environment default). Setting client concurrency higher than the OSRM thread count does not parallelize computation — it just queues requests server-side, which is functionally equivalent to sending them sequentially but with added HTTP overhead per request. Match CONCURRENCY to the engine’s actual worker count, verified with osrm-routed --help or the container’s startup logs, as described in deploying OSRM with Docker for local routing.
Retry policy is the other half of this, and the failure it has to distinguish is not transient versus permanent — it is engine under momentary load versus engine wedged. Exponential backoff with a small attempt ceiling separates them, because a genuinely transient 503 clears within a second or two while a wedged instance never does.
Integration Points
Upstream: chunk boundaries. This page assumes origin_chunks and dest_chunks already exist as lists of coordinate lists sized under the engine’s per-request cap. See chunking large origin-destination matrices for how to compute chunk sizes from --max-table-size, exploit origin/destination symmetry, and reuse previously computed blocks.
Downstream: solver input. The assembled matrix array is a direct drop-in for OR-Tools’ RoutingIndexManager distance callback or any VRP solver expecting a dense cost matrix — index it as matrix[from_index][to_index] after converting to int (most solvers require integer costs; multiply by 100 and round if sub-second precision matters).
Engine choice affects chunk shape. If comparing engines for the same fleet workload, note that OSRM’s coordinate ceiling is a flat count across sources and destinations combined, while Valhalla enforces max_matrix_distance as a geographic bound in addition to a location count — a chunk sized correctly for OSRM may still be rejected by Valhalla if origins and destinations span a wide area. The trade-offs are covered in more depth for freight-specific routing in OSRM vs Valhalla vs GraphHopper for freight routing.
Caching partial results. Because each chunk writes to a known (row_offset, col_offset) block, a chunk-level cache keyed on the origin/destination coordinate hash lets you skip re-fetching unchanged blocks when only a subset of stops changes between runs — useful for daily depot-to-stop matrices where the depot side rarely moves.
Validation Checklist
-
No
NaNcells remain. Afterbuild_matrixcompletes without raising, assertnot np.isnan(matrix).any(). Any survivingNaNmarks a chunk that silently failed all retries and was swallowed — checkasyncio.gather(*tasks, return_exceptions=True)output during development to catch this before it reaches production. -
Shape matches expected dimensions.
matrix.shape == (n_origins, n_destinations)— a mismatch usually means a chunk’s coordinate order in the OSRM response did not match the ordersources/destinationsindices were built in. -
Semaphore value never exceeds connector limit. Assert this at startup:
CONCURRENCY <= POOL_LIMIT. Violating it does not raise an error — it degrades silently into queuing, which is the hardest version of this bug to diagnose in production. -
Symmetric-route sanity check. For a handful of origin-destination pairs, compare
matrix[i][j]against a direct single-pair/routerequest. Differences beyond a few percent usually indicate a coordinate order bug (lon,latvslat,lon— OSRM expectslon,latin the URL, the opposite of most GeoJSON tooling). -
Timeout coverage under load. Run the full batch against a rate-limited or intentionally slow local proxy and confirm no request hangs past
REQUEST_TIMEOUT.total. A missingsock_readtimeout is the most common cause of a batch that never completes. -
Retry count stays bounded. Log retry attempts per chunk; if a large fraction of chunks hit the 3-attempt ceiling,
CONCURRENCYis likely too high for the engine’s worker pool rather than the network being unreliable.
Why do some chunks return HTTP 400 while identical-sized neighboring chunks succeed?
This is usually a coordinate-count-off-by-one from chunk boundary math, not the engine. OSRM’s --max-table-size counts total coordinates (origins + destinations) in the request, not rows or columns separately. If chunk sizing was computed from --max-table-size without accounting for both dimensions combined, the last chunk in an uneven partition can exceed the cap by a handful of coordinates while earlier uniform chunks stay under it. Recompute chunk cell budgets as origin_chunk_len + dest_chunk_len, not origin_chunk_len * dest_chunk_len.
Related
- Async route matrix automation — the parent guide covering concurrency control, chunk tiling strategy, and matrix assembly at a conceptual level
- Chunking large origin-destination matrices — how to size and partition chunks before they reach the fan-out layer described here
- Deploying OSRM with Docker for local routing — configuring the OSRM thread pool and container resources that bound realistic client concurrency
- OSRM vs Valhalla vs GraphHopper for freight routing — how each engine’s matrix size limits differ when choosing a production routing backend