Skip to main content

Performance Optimization Guide

The VRP solver’s performance is governed by a small number of real levers: the time_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 optionstime_limit_s directly controls how many ALNS iterations run; seed doesn’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_CONCURRENCY concurrent 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.
There is no 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.
The default 1-second budget is usually sufficient. Expect thousands of iterations and near-optimal solutions for straightforward capacity/time-window problems at this size.
For problems under 50 jobs, the 1-second default is almost always enough — additional time rarely changes the result. For 100+ jobs, start around 3-5 seconds. Beyond 10 seconds, improvements are typically marginal for any single problem; that time budget is usually better spent running the same problem with 3-5 different seed values and keeping the best result, since seeds explore genuinely different neighborhoods rather than just refining the same one further.

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 no moveThreadCount 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.
If you are self-hosting and see 503s under load, check 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 no matrixId 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. Every POST /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.
There are no 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.
If jobs_unassigned is non-zero, don’t reach for a bigger time_limit_s first — check the unassigned[] array. Each entry carries a structured reasons/relaxations payload (see Constraint System) that usually tells you it’s a hard constraint (capacity, a time window, a skill mismatch) rather than a search-time problem. More iterations cannot place a job the constraints make infeasible for every vehicle.

Best Practices

Performance tuning checklist:
  1. Start with the 1-second default. For problems under ~100 jobs with ordinary constraints, it’s almost always enough.
  2. 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.
  3. Check summary.iterations before 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.
  4. Try multiple seed values if you have spare time budget, and keep the best result — this explores different neighborhoods rather than refining the same one longer.
  5. Diagnose jobs_unassigned via unassigned[].reasons, not via more time. A hard-constraint rejection doesn’t get fixed by additional ALNS iterations.
  6. Size your deployment by cores, not by request options. Each solve gets one ALNS thread; total throughput is SOLVER_CONCURRENCY concurrent solves plus a SOLVER_MAX_WAITING queue — see the sizing table above.
  7. 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.
  8. Use initial_routes for re-optimization instead of re-solving cold. There is no matrixId and 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.

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