Skip to content
// booting portfolio
the engineer's notebookYash.
loading assets000%
Yash.
All writing
FastAPICeleryRedisOptimization

Keeping a route-optimization dashboard responsive with Celery + VROOM

5 min read

Solving the Vehicle Routing Problem (VRP) — assigning stops to vehicles and ordering them under capacity and time-window constraints — is NP-hard. Even with a great solver like VROOM, a real instance can take seconds. That's fine for a batch job and fatal for a dashboard where someone just clicked Optimize.

The fix is old but reliable: get the work off the request path.

The shape of the problem

A synchronous endpoint that solves inline holds the connection open, ties up a worker, and times out under load. Instead, the API should accept the job, hand back an id, and let the client poll.

# api.py — FastAPI accepts the job and returns immediately
@app.post("/optimize")
async def optimize(req: OptimizeRequest):
    task = solve_routes.delay(req.model_dump())
    return {"job_id": task.id, "status": "queued"}

@app.get("/optimize/{job_id}")
async def status(job_id: str):
    res = AsyncResult(job_id, app=celery_app)
    return {"status": res.status, "result": res.result if res.ready() else None}

Celery does the heavy lifting

The actual solve runs in a Celery worker backed by Redis. VROOM gets the matrix from OSRM and returns ordered routes.

# tasks.py
@celery_app.task(bind=True)
def solve_routes(self, payload):
    problem = build_vroom_problem(payload)        # vehicles, jobs, capacities
    solution = vroom_solve(problem)               # calls VROOM + OSRM
    persist_metrics(solution)                     # PostgreSQL
    return serialize(solution)

Because the queue is concurrent, ten users can optimize at once without one blocking another — you just scale workers.

Polling, the client side

On the Next.js dashboard, TanStack Query turns polling into three lines. refetchInterval stops once the job is done.

const { data } = useQuery({
  queryKey: ["opt", jobId],
  queryFn: () => fetch(`/optimize/${jobId}`).then((r) => r.json()),
  refetchInterval: (q) => (q.state.data?.status === "SUCCESS" ? false : 1500),
  enabled: !!jobId,
});

What I'd tell past me

  • Treat the solver as a black box behind a queue. It makes scaling a config change, not a rewrite.
  • Persist every run. Optimization logs and metrics in PostgreSQL turned "is it working?" into a chart.
  • Cache the OSRM matrix. Distance/duration matrices are the slow part; Redis-caching them per region cut solve setup time noticeably.

The whole thing shipped as Velora and picked up a Bronze at Kriti — but the real win was a dashboard that stays buttery while a solver grinds in the background.

Comments (0)

  • Be the first to comment.