Performance Optimization Guide
The VRP solver’s performance is governed by a small number of real levers: thetime_limit_s budget you set per request, the size and shape of the problem (jobs, vehicles, active constraints), and how many solves run concurrently on the machine serving them. This guide describes those levers as they exist in POST /v3/routing/solve today — not a generic tuning framework borrowed from another engine.
This solver runs a single algorithm, Adaptive Large Neighborhood Search (ALNS) — there is no
constructionHeuristic or localSearchType switch to choose between different algorithm families, and no moveThreadCount to raise per-request parallelism. There is no matrixId for persisting a matrix across requests, but there is a warm start: initial_routes lets a re-optimization begin from the plan you already have. Every option named below is a real field in the current request schema or a real, documented deployment constant.What Affects Solve Time?
- Problem size — more jobs means more candidate insertion positions to scan, both in repair and in the periodic inter-route local-search pass; see Where the Time Actually Goes. This is the single biggest driver of iteration count for a fixed time budget.
- Active constraints — the solver only pays for the constraint types a request actually activates. A plain capacitated problem with no time windows, skills, or relations checks exactly one constraint per candidate position; adding time windows, skills, or relations adds real per-position or per-route cost. See Constraint System for the full breakdown.
- Request options —
time_limit_sdirectly controls how many ALNS iterations run;seeddoesn’t affect speed but does affect which part of the solution space gets explored. - Server load — every request gets exactly one ALNS thread; if the server is already running
SOLVER_CONCURRENCYconcurrent solves, your request queues instead of running immediately. See Concurrency and Sizing below.
How More Time Becomes a Better Solution
The solver runs Adaptive Large Neighborhood Search (ALNS): construct an initial solution with regret-2 insertion, then repeatedly destroy part of it and repair it, keeping the best result found. This is an anytime algorithm — there is no separate “fast mode” or “quality mode” algorithm choice. The only dial is how long it runs: More iterations means more destroy/repair/local-search cycles explored, which means a better (or equal) solution — never a worse one, since the solver only keeps the best solution it has found. But the relationship has strong diminishing returns: most of the improvement happens early in the budget, and doubling the budget does not halve the cost.The figures above use the ~5 iterations/millisecond order of magnitude the solver’s own calibration uses for a ~100-job instance. Treat it as an order of magnitude, not a promise: actual throughput depends on job count, vehicle count, and which constraints are active — a problem with driver breaks, many time windows, or multi-trip reloads does far fewer iterations per second than a plain CVRP of the same size, because each iteration does more work. The number that matters for your problem is the one the response reports: compare
summary.iterations against your time_limit_s.The Real Tuning Knob: options.runtime.time_limit_s
integer
default:"1"
Wall-clock solve-time budget, in seconds. Default
1, maximum 300 — a larger value is rejected with HTTP 400. This is pure search time: matrix fetch (the Solvice Maps round trip) and any time spent queued behind other concurrent solves are bounded separately and are not deducted from this budget. Source: RuntimeOptions.time_limit_s, src/api/v3_types.rs.integer (u64)
Random seed for the ALNS search’s pseudorandom number generator. Because the search is bounded by wall-clock time rather than iteration count, the same seed usually reproduces an identical plan on one machine but a materially faster or slower machine can diverge — it is a reproducibility aid, not a determinism guarantee. Different seeds explore different neighborhoods, so trying a handful and keeping the best result is a legitimate quality lever when you have budget to spare. Source:
RuntimeOptions.seed, src/api/v3_types.rs.string (RFC 3339)
Departure instant for the traffic lookup — plan tomorrow’s 08:00 routes against morning-rush travel times instead of traffic at solve time. Note this bypasses the server-side matrix cache, so a request that pins a departure time always pays a full matrix fetch. Source:
TrafficOptions.departure_time, src/api/v3_types.rs.There is no caller-supplied matrix field:
options.runtime.matrix was removed as a Phase-1 stub that never shipped, so every solve fetches live from Solvice Maps. See Distance Matrix Integration for what that fetch costs and when the cache absorbs it.constructionHeuristic, localSearchType, nearbySelection, or per-request moveThreadCount field in the schema. Beyond time_limit_s, the one lever that changes where the search starts rather than how long it runs is initial_routes — a warm start from an existing plan, which is what re-optimization should use rather than re-solving from scratch.
Problem Size Guidelines
These are rough starting points, not guarantees — actual iteration count for a given time budget depends heavily on which constraints your problem activates (see above). Treat them as a starting point to tune from, not a promise.- Small (< 100 jobs)
- Medium (100-500 jobs)
- Large (500-2000 jobs)
- Very Large (2000+ jobs)
Where the Time Actually Goes
There is no single bottleneck — which phase dominates depends on the shape of your problem. The most recent instruction-level profile (callgrind, 2026-08-30, five benchmark instances) measured:
On a well-fed dense instance the periodic inter-route pass dominates; repair only takes over where the field-service segment lives — multi-trip reloads, driver breaks, and over-constrained fleets. An older “repair is 80–93% of ALNS time” figure circulated before the production-parity benchmark fixtures existed; it no longer holds and is not a useful planning number.
Two things follow for tuning. First, problem size still drives iteration count more than anything else, because both dominant phases scale with route count × route length. Second, adding constraints your request doesn’t need is not free — driver breaks in particular shift the whole cost profile, with route re-timing alone accounting for about a third of the solve.
Concurrency and Sizing
Solve throughput on a given machine is bounded by CPU cores, not by any per-request setting. Each incoming solve request gets exactly one ALNS thread — there is nomoveThreadCount or similar option to make a single request use more cores. Instead, the server runs multiple requests concurrently, up to SOLVER_CONCURRENCY (default: available_parallelism - 1, reserving one core for the HTTP/async runtime). Requests beyond that concurrency limit queue; once the queue reaches SOLVER_MAX_WAITING (default: 2 x SOLVER_CONCURRENCY), new requests are rejected with HTTP 503 rather than queued indefinitely.
The concurrency permit is acquired after the Solvice Maps matrix fetch completes, so a request that’s waiting on network I/O for its distance matrix doesn’t tie up a CPU permit that another request’s ALNS search could be using.
Sizing guide for a VM, following directly from the SOLVER_CONCURRENCY/SOLVER_MAX_WAITING defaults:
“Concurrent solves” is
cores - 1 and “queue depth” is 2 x that number — these follow directly from the SOLVER_CONCURRENCY/SOLVER_MAX_WAITING defaults, not from a separate sizing formula. If your workload needs more throughput than a given VM size supports, the answer is more cores (or more instances behind a load balancer using GET /health’s queue stats as the readiness signal), not a request-level tuning knob — there isn’t one for this.GET /health for solver_active/solver_waiting before assuming it’s a solve-quality problem — it may simply mean the queue is full.
Distance Matrix Performance
Every solve fetches a real road-network distance/duration matrix from Solvice Maps before the ALNS search starts — there is no selectable Euclidean or Haversine fallback mode, and nomatrixId persistent-cache field on the request. How that fetch is performed (the sync-vs-async cutover at 150 coordinates, the in-process pairwise cache and its CACHE_MAX_COORDS/CACHE_TTL_SECS constants, and what options.runtime.traffic does to it) is covered in full in Distance Matrix Integration — this page won’t duplicate it.
The one fact worth repeating here: matrix fetch time and queue wait are not deducted from time_limit_s. A slow matrix fetch or a long queue wait shows up as added wall-clock latency on top of your configured time limit, not as a smaller search budget. Source: RuntimeOptions.time_limit_s, src/api/v3_types.rs.
Reading Performance From the Response
There is no separate performance-monitoring endpoint or opt-in diagnostics flag. EveryPOST /v3/routing/solve response already carries the real numbers you need in summary:
integer (u64)
Number of ALNS iterations completed within the time budget. If this is only a few hundred for a problem with hundreds of jobs, the time budget was likely too tight for the problem size — raise
time_limit_s. Source: Summary.iterations, src/api/v3_types.rs.integer (u64)
Wall-clock time taken by the entire request in milliseconds — this includes matrix fetch and problem conversion, not just the ALNS search itself, so it will generally be larger than
time_limit_s x 1000. Source: Summary.elapsed_ms, src/api/v3_types.rs.string enum
One of
solved (every job assigned), partial (some jobs unassigned), or infeasible (no job could be served). Source: SolveStatus enum, src/api/v3_types.rs.scoreCalculationCount, moveEvaluationCount, or bestScoreTime fields anywhere in this schema — those come from a different engine’s scoring model and don’t apply here. If you need to know whether the search had enough time, summary.iterations next to your requested time_limit_s is the real signal: compare it against the throughput guidance in How More Time Becomes a Better Solution for a problem of similar size.
Best Practices
Performance tuning checklist:
- Start with the 1-second default. For problems under ~100 jobs with ordinary constraints, it’s almost always enough.
- Raise
time_limit_s, not a construction/local-search algorithm choice. There is one algorithm; the only per-request dial is how long it runs. - Check
summary.iterationsbefore assuming more time will help. A low iteration count for your problem size means the budget was tight; a high one that still isn’t improving means you’ve hit diminishing returns. - Try multiple
seedvalues if you have spare time budget, and keep the best result — this explores different neighborhoods rather than refining the same one longer. - Diagnose
jobs_unassignedviaunassigned[].reasons, not via more time. A hard-constraint rejection doesn’t get fixed by additional ALNS iterations. - Size your deployment by cores, not by request options. Each solve gets one ALNS thread; total throughput is
SOLVER_CONCURRENCYconcurrent solves plus aSOLVER_MAX_WAITINGqueue — see the sizing table above. - Watch
GET /health(solver_active,solver_waiting) if you’re seeing HTTP 503s under load — that’s a capacity signal, not a solve-quality one. - Use
initial_routesfor re-optimization instead of re-solving cold. There is nomatrixIdand no caller-supplied matrix — every solve fetches from Solvice Maps — but starting the search from the plan you already have is both faster to a good answer and far kinder to plan stability.
Related Topics
Distance Matrices
How the matrix fetch, sync/async cutover, and pairwise cache work
Constraint System
Which constraints are cheap (O(1)) versus which scan the route
Solution Quality
Balancing speed against solution quality in more depth
Objective Function
How the two-tier objective ranks solutions once they’re feasible