Skip to content
Go back

BRouter: Cycling Routes That Don't Suck

By SumGuy 11 min read
BRouter: Cycling Routes That Don't Suck

The Route That Nearly Killed Me

Not literally. But Strava’s “fastest route” feature sent me down a four-lane arterial with no shoulder, three construction zones, and a bridge with a lane width that made me question every decision I’d ever made. Google Maps, when asked for “cycling directions,” gave me a road that cyclists are legally prohibited from using in my state. Neither app had any concept of what a bike actually wants.

This is not a niche problem. Every cyclist who has used a mainstream routing app has a version of this story. The apps are not wrong, exactly — they’re just solving for minimum time in a model that doesn’t understand that “minimum time” on a bike involves not dying. They don’t know that a gravel surface adds 20% rolling resistance. They don’t know that a 12% grade is a 10-minute slog for most humans on a loaded bike. They don’t know that a “quiet residential street” with car-dooring risk is worse than the slightly longer protected path one block over.

BRouter knows all of that. It’s a Java-based bicycle routing engine that uses OpenStreetMap segment data and a scoring DSL to model exactly these tradeoffs. It’s not flashy, but the routes it produces are what an experienced cyclist would actually choose.

Full example: Compose + a custom commute profile at github.com/KingPin/sumguy-examples/tree/main/self-hosting/brouter-cycling-routing

What’s Actually Wrong With Car-Brained Routing

Google Maps has a cycling mode. Strava has route suggestions. Here’s what they have in common: they treat a bike like a slow car.

Car routing considers distance, speed limits, and turn complexity. When adapted for bikes, someone adds “avoid highways” and calls it done. What it doesn’t model:

Surface type. OSM tags every segment — asphalt, gravel, compacted, dirt, sand. A road bike on gravel loses 20% efficiency. Car-brained routers treat all passable surfaces identically.

Elevation gradient. Standard routing treats climbing as a rough afterthought. Real cycling routing needs the gradient of each segment — 200m spread over 10km versus a 400m wall at the end are completely different rides.

Traffic exposure. A “bike-permitted” road where cars do 70 mph with no shoulder isn’t a cycling route. It’s a dare. Good routing weights these heavily or removes them from contention entirely.

Cycleway type. OSM distinguishes cycleway=track (separated), cycleway=lane (painted), cycleway=shared (symbols only). These are not the same experience and different riders have opposite preferences.

BRouter models all of this through configurable profile files. The core insight: routing quality is a function of how accurately your cost model reflects your preferences.

BRouter’s Architecture: Routing Engine, Not Navigation App

Before you get disappointed that it doesn’t have turn-by-turn voice guidance, understand what BRouter is: a routing engine. It takes a start point, an end point, and a profile, and returns a route as a GPX/GeoJSON file. What you do with that route is up to you.

The system has three pieces:

Segment data — BRouter downloads OSM data pre-sliced into 5°×5° geographic tiles (called “segments”). Each tile is a compact binary file containing all OSM way data and elevation information for that region. You download only the tiles you actually ride in. These live on disk and get memory-mapped at query time — that’s why it’s fast.

Profile files.brf files written in BRouter’s own expression language. They define how each road segment is scored: base cost, surface cost modifier, gradient penalty, allowed/forbidden way types. BRouter ships with profiles for trekking, fast road cycling, mountain biking, and safety-first commuting. You can write your own.

HTTP API — The daemon exposes a REST API on port 17777. Send it two lat/lon pairs plus a profile name and it returns a route. That’s the integration point for everything else: OsmAnd, web frontends, scripts.

No user interface ships with the server itself. It’s infrastructure, not an app. That’s the right call — it means BRouter can serve whatever frontend you prefer, and its job is just to route well.

Docker Setup

The community image is nrenner/brouter. Start with this Compose file:

docker-compose.yml
services:
brouter:
image: nrenner/brouter:1.7.5
container_name: brouter
ports:
- "17777:17777"
volumes:
- brouter-segments:/brouter/segments
- ./profiles:/brouter/profiles
environment:
- JAVA_OPTS=-Xmx512m -Xms256m
restart: unless-stopped
volumes:
brouter-segments:

The profiles/ directory is mounted from the host so you can edit profiles without rebuilding the container. The segments/ volume is where the geographic tiles live.

Bring it up:

Terminal window
docker compose up -d
docker compose logs -f brouter

The first start will fail with no segment data — that’s expected. You need to download tiles before routing works.

Downloading Segment Data

BRouter reads pre-built segment files from brouter.de — 5°×5° tiles named by their southwest corner. Download only what you need.

Terminal window
# Download tiles for your region (western US example)
# Each tile is 20-150 MB depending on OSM density
docker exec brouter wget -P /brouter/segments \
https://brouter.de/brouter/segments4/W120_N35.rd5 \
https://brouter.de/brouter/segments4/W115_N35.rd5 \
https://brouter.de/brouter/segments4/W110_N30.rd5

Figure out which tiles cover your area using the BRouter tile index. The naming is the southwest corner lon/lat — W75_N40 covers the US Northeast.

Once you have tiles, verify the container is routing:

Terminal window
curl "http://localhost:17777/brouter?lonlats=-87.65,41.85|-87.63,41.87&profile=trekking&alternativeidx=0&format=geojson"

GeoJSON back = working. Error about no segment data = wrong tiles.

The Profile DSL: How BRouter Actually Scores Routes

This is the interesting part. BRouter’s .brf profile format is a simple expression language that runs against each way segment’s OSM tags. The result is a cost value — lower cost means BRouter prefers that segment.

Here’s a simplified excerpt from trekking.brf to show the structure:

profiles/trekking.brf (excerpt)
---context:way
# assign initial cost based on highway type
assign initialcost =
if highway=motorway then 10000 # effectively forbidden
else if highway=trunk then 1000
else if highway=primary then 100
else if highway=secondary then 50
else if highway=tertiary then 30
else if highway=residential then 10
else if highway=path then 20
else if highway=cycleway then 0 # prefer dedicated infrastructure
else 50
# surface penalty — applied on top of initialcost
assign surfacecost =
if surface=asphalt then 0
else if surface=paved then 0
else if surface=compacted then 10
else if surface=gravel then 20
else if surface=dirt then 40
else if surface=sand then 200
else 10
# elevation penalty per meter of climbing
assign uphillcost = 60
# final cost per km of this segment
assign cost = add initialcost surfacecost

That’s the idea. Every segment gets a base cost from its highway= tag, a surface penalty from its surface= tag, and a climbing penalty proportional to gradient. The router finds the path that minimizes total cost — not total distance. A 1km cycleway on asphalt scores far cheaper than a 0.8km arterial on pavement.

The shipping profiles cover the main use cases:

Writing a Custom Commute Profile

The shipping profiles are good starting points but real life is more specific. Here’s a practical custom profile for an urban commuter who wants to minimize car exposure without sacrificing too much time:

profiles/commute.brf
---context:global
assign mincostperklm = 1.40 # base cost floor per km
---context:way
# Hard block on genuinely dangerous ways
assign blocked =
or highway=motorway
or highway=motorway_link
highway=trunk
# Base cost from infrastructure quality
assign basecost =
if cycleway=track then 0 # fully separated — maximum preference
else if cycleway=lane then 5 # painted lane, acceptable
else if highway=cycleway then 0 # dedicated cycle path
else if highway=path then 15
else if highway=residential then 20
else if highway=tertiary then 35
else if highway=secondary then 60 # heavy penalty, high traffic
else if highway=primary then 100
else 40
# Surface matters less to a commuter (most urban streets are paved)
# but still penalize obvious issues
assign surfacecost =
if surface=asphalt then 0
else if surface=paved then 0
else if surface=cobblestone then 30 # hard on everything
else if surface=gravel then 40
else 10
# Climbing penalty — commuters don't want to arrive sweaty
assign uphillcost = 80
assign cost = add basecost surfacecost

Drop this in your mounted profiles/ directory. Reference it in API calls as commute. No container restart needed — BRouter reloads profiles per request.

Querying the API

The HTTP API is simple. The main endpoint is /brouter:

Terminal window
# Route from one point to another using the commute profile
curl "http://localhost:17777/brouter?\
lonlats=-87.6298,41.8781|-87.6168,41.8655\
&profile=commute\
&alternativeidx=0\
&format=geojson" > route.geojson
# Get an alternative route (alternativeidx=1 gives the second-best option)
curl "http://localhost:17777/brouter?\
lonlats=-87.6298,41.8781|-87.6168,41.8655\
&profile=commute\
&alternativeidx=1\
&format=gpx" > route_alt.gpx

Parameters worth knowing:

The response includes elevation data and track stats (distance, climb, descent) in the properties. No post-processing needed.

OsmAnd Integration: BRouter on Your Phone

OsmAnd (Android) has native BRouter support. Configure it under Settings → Navigation settings → Route parameters → Other → Routing engine. Select “BRouter (online)” — despite the label, it accepts a local IP — then enter http://YOUR_LAN_IP:17777 and a profile name.

From there, OsmAnd routes through your BRouter instance and renders on its own offline maps. BRouter’s routing quality, OsmAnd’s turn-by-turn voice guidance. That’s the combination that actually works.

One catch: your phone needs to reach the server. Same LAN works trivially. For routing while out on the road, either Tailscale the connection or pre-plan routes at home and export GPX before you leave — which is honestly the simpler workflow anyway.

The Elevation Math Nobody Talks About

Cyclists talk about “total elevation gain” but gradient distribution is what actually determines effort. Two routes both gaining 150m:

Most routing engines model these identically. 150m is 150m. BRouter’s uphillcost is applied per segment based on actual gradient. A 10% grade is penalized much more heavily than a 3% grade covering the same vertical meters. This is why BRouter sometimes picks a route that looks longer on the map — the climbing is distributed across grades that don’t destroy your legs.

Limitations Worth Knowing

BRouter is a routing engine, not a fitness app and not a navigation app. A few things it doesn’t do:

No live navigation. BRouter returns a route file. It doesn’t track your position or give turn-by-turn prompts. That’s OsmAnd’s job (or Komoot, or whatever frontend you prefer).

No real-time data. Road closures, temporary conditions, current traffic — none of that affects BRouter. It routes from static OSM data. For cycling infrastructure this matters less than for driving.

Segment data ages. BRouter tiles are pre-built OSM snapshots. Re-download tiles every few months to pick up new bike paths and infrastructure changes.

Profile tuning takes iteration. Write a profile, ride a route, adjust the weights based on what it got wrong. The DSL is simple but good balance between climb penalty, surface penalty, and road type takes a few rides to dial in.

The core value is narrow and real: better bicycle routing than anything car-centric.

One Container, Better Routes

One Docker image, a handful of geographic tile files, a profile that reflects how you actually ride. The container is lightweight — 512 MB heap handles most workloads. Segment tiles are the only meaningful storage cost, and you only download what you need.

Wire BRouter into OsmAnd, pre-download GPX tracks before rides, and stop arguing with your phone about whether that arterial is “really fine for bikes.”

Your 2 AM self won’t have to explain to the ER why you were on that road.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Next Post
Boundary vs Teleport

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts