DEV Community

Cover image for Why I Built My Own OpenStreetMap Routing Engine — and How I Use It in a Real Application
Daniel Sá
Daniel Sá

Posted on

Why I Built My Own OpenStreetMap Routing Engine — and How I Use It in a Real Application

Building roadgraph-router-v2: from a practical mapping problem to a lightweight, browser-based routing engine.

Sometimes, the best way to understand a technology is to build something that depends on it.

That's how roadgraph-router-v2 started.

I was developing Energy&Fuel, a web application for exploring electric vehicle charging stations and fuel stations across Portugal.

The idea was relatively straightforward: bring together information about EV charging infrastructure, fuel prices, and station locations in one interactive map.

But as the project evolved, I wanted to go beyond displaying markers.

I wanted users to calculate actual road routes, compare alternatives, and understand how to reach a particular station.

That led me to an interesting problem:

Could I implement routing directly in the browser without relying on an external routing API?

The result was roadgraph-router-v2, an open-source TypeScript routing engine built around OpenStreetMap data, compact road graphs, and A* pathfinding.

In this article, I'll explain why I built it, how it works, and how I'm using it in Energy&Fuel.


1. The Project That Started Everything

Energy&Fuel is an interactive map focused on Portugal's energy and mobility infrastructure.

It brings together two major datasets:

  • More than 3,000 EV charging stations from the MOBI.E network.
  • More than 6,000 fuel stations using data from Portugal's Direção-Geral de Energia e Geologia (DGEG).

The application uses Next.js, React, TypeScript, and mapping libraries to display stations, prices, and geographic information.

Users can explore nearby stations, filter by fuel type, compare prices, and look for charging infrastructure.

You can explore the application here:

Energy&Fuel — Live Application

The routing problem

Finding the nearest station is relatively simple when considering geographic distance.

However, geographic distance and driving distance are not the same thing.

Imagine two fuel stations:

  • Station A is 2 km away in a straight line.
  • Station B is 3 km away in a straight line.

Station A might appear closer, but reaching it could require driving around a river, following a one-way system, or using a distant motorway exit.

In practice, Station B might be the better destination.

To address this, I needed a routing engine capable of understanding an actual road network.

Seeing the routing engine in action

Here's an example of roadgraph-router-v2 running inside Energy&Fuel:

In this example, the application calculates a journey of approximately 664 km across Portugal, with an estimated driving time of 5 hours and 48 minutes.

The interface displays three alternative routes and allows users to switch between cars, motorcycles, and trucks.

What's interesting isn't necessarily the route itself, but where the calculation happens.

Instead of sending the coordinates to an external directions API, Energy&Fuel uses roadgraph-router-v2 to calculate the route locally in the browser.

The routing engine operates inside a Web Worker, reading the necessary sections of a precompiled OpenStreetMap graph.

The application can therefore provide road routing without operating its own routing backend or depending on a third-party directions service.

Why not use an existing routing API?

There are already excellent routing solutions available.

Projects such as OSRM, GraphHopper, and Valhalla demonstrate how sophisticated open-source routing infrastructure has become.

I wasn't trying to replace them.

However, for my application, I wanted to explore a different architecture.

My objectives were:

  1. Avoid requiring a separately hosted routing service.
  2. Keep the routing logic independent of a commercial API.
  3. Use OpenStreetMap as the underlying geographic dataset.
  4. Support route calculations inside the browser.
  5. Minimize the amount of geographic data downloaded by users.
  6. Make the routing engine reusable in other projects.

I also wanted to understand the underlying algorithms rather than simply integrating another API.

This became the foundation of roadgraph-router-v2.


2. Introducing roadgraph-router-v2

roadgraph-router-v2 is a TypeScript library that transforms OpenStreetMap extracts into compact routing graphs and uses them to calculate routes.

The project is available on GitHub:

GitHub — roadgraph-router

And on npm:

npm — roadgraph-router-v2

The package handles two main responsibilities.

Preparing geographic data

OpenStreetMap provides detailed information about roads, intersections, access restrictions, and other geographic features.

However, raw .osm.pbf files aren't optimized for direct browser-based routing.

The first step is converting the relevant data into a graph that a routing algorithm can efficiently traverse.

roadgraph-router-v2 includes a compiler that produces a compact .efrb bundle.

Calculating routes

Once the graph is prepared, the routing engine can calculate paths between geographic coordinates.

The engine uses the A* algorithm, which explores a road network while using a geographic heuristic to guide the search toward the destination.

This makes it possible to calculate routes without contacting a remote routing server.

The package supports both Node.js and modern browsers.

Supported routing profiles

At the time of writing, version 0.4.0 supports seven profiles.

Road networks:

  • Car
  • Motorcycle
  • Truck
  • Bicycle
  • Pedestrian

Railway infrastructure:

  • Train
  • Metro

Railway routing is focused on mapped physical infrastructure, not passenger timetables or live train services.

Different transport networks can require separate compiled bundles.

For Energy&Fuel, the important profiles are car, truck, and motorcycle.


3. Compiling OpenStreetMap Data

Before calculating routes, we need road network data.

For Energy&Fuel, I use an OpenStreetMap extract of Portugal.

The first step is installing the package:

npm install roadgraph-router-v2
Enter fullscreen mode Exit fullscreen mode

The routing compiler requires Node.js 22.5 or newer.

Once installed, we can generate a routing bundle:

npx roadgraph-compile portugal.osm.pbf \
  --output portugal.efrb \
  --profile car,truck,motorcycle
Enter fullscreen mode Exit fullscreen mode

This produces a file containing the compiled road network.

Instead of processing raw OpenStreetMap data every time the application starts, the preprocessing work is performed beforehand.

The browser only needs to read the compiled bundle.

Why the compiled format matters

One of my main concerns was the size of the geographic data.

A national road network contains a considerable amount of information.

Downloading and loading the entire graph before calculating a simple journey would be inefficient.

The .efrb format addresses this by supporting range-based access.

Rather than downloading everything, the router can retrieve the portions of the bundle needed for a particular route.

For example, the package's documented Portugal benchmark reports:

Metric Result
Original Portugal OSM extract 425 MB
Compiled routing bundle 38 MB
Data downloaded for a city route 4.3 MB
End-to-end city route calculation ~270 ms
Browser library size 24.5 kB gzipped

These are measurements from the package's documented benchmark, not guarantees for every machine, network, or route.

Nevertheless, they demonstrate why selective graph loading can make browser-based routing practical.

Compiling the Energy&Fuel dataset

In the actual Energy&Fuel project, the compilation process is integrated into npm scripts.

The command follows this structure:

npm run routing:compile -- portugal-261003.osm.pbf \
  --output public/routing/portugal-v10.efrb \
  --profile car,truck,motorcycle
Enter fullscreen mode Exit fullscreen mode

The generated file is placed in the application's public directory.

That means the browser can request the routing bundle as a static resource.

No separate routing API is required.

One important deployment detail: the server must preserve HTTP Range request behavior for .efrb files. Whole-file HTTP content encoding can interfere with accessing individual byte ranges.


4. Calculating Our First Route

The public API is designed to be straightforward.

Let's calculate a route between two coordinates in Monaco.

import { RoadRouter } from "roadgraph-router-v2";

const router = await RoadRouter.open(
  "/routing/monaco.efrb"
);

const origin = {
  lat: 43.7384,
  lng: 7.4246
};

const destination = {
  lat: 43.7325,
  lng: 7.4189
};

const result = await router.route(
  origin,
  destination
);

console.log({
  distance: result.distanceMeters,
  duration: result.durationSeconds,
  geometry: result.geometry
});
Enter fullscreen mode Exit fullscreen mode

The result contains the calculated road distance, an estimated travel duration, and the geometry of the route.

The geometry is particularly useful because it can be rendered directly on an interactive map using GeoJSON-compatible mapping tools.

The routing calculation happens inside the library, using the compiled graph.

At this point, we've implemented routing without making a request to an external directions API.

However, this is only a simple example.

In a real web application, there is another problem to solve.


5. Keeping the Interface Responsive with Web Workers

One of the challenges I encountered was deciding where routing calculations should execute.

Even if a routing engine is efficient, decoding road network data and searching a graph requires CPU work.

Running this directly on the browser's main thread can affect responsiveness.

For a map-based application, that's particularly noticeable.

Users expect to be able to zoom, pan, and interact with markers while calculations are taking place.

In Energy&Fuel, I decided to move the routing engine into a Web Worker.

The architecture looks like this:

                  Energy&Fuel
                 React / Next.js
                       |
                       |
                Routing Client
                       |
                       | postMessage()
                       |
                 Web Worker
                       |
                       v
              roadgraph-router-v2
                       |
                       v
              Compiled OSM Graph
                       |
                       v
                A* Pathfinding
                       |
                       |
                Route Result
                       |
                       v
                 Map Interface
Enter fullscreen mode Exit fullscreen mode

The main thread sends a request to the worker.

The worker calculates the route and returns the result.

This separation allows route calculations to run without occupying the UI thread.

The actual worker implementation

Energy&Fuel uses the following pattern:

import {
  RoadRouter,
  type Coordinate,
  type RoutePreference
} from "roadgraph-router-v2";

let base: RoadRouter | null = null;

self.onmessage = async ({ data }) => {
  try {
    if (data.type === "open") {
      base = await RoadRouter.open(data.url);

      self.postMessage({
        id: data.id,
        ok: true,
        value: {
          profiles: base.profiles
        }
      });

      return;
    }

    if (!base) {
      throw new Error("Routing graph not loaded");
    }

    const result = await base.route(
      data.from,
      data.to,
      data.preference ?? "fastest"
    );

    self.postMessage({
      id: data.id,
      ok: true,
      value: result
    });

  } catch (error) {
    self.postMessage({
      id: data.id,
      ok: false,
      error: error instanceof Error
        ? error.message
        : "Routing failed"
    });
  }
};
Enter fullscreen mode Exit fullscreen mode

This is a simplified version of the worker used in Energy&Fuel.

The actual implementation also handles multiple vehicle profiles, alternative routes, departure times, and router caching.

Why this architecture matters

The result is a clean separation between the application and the routing engine.

The React components don't need to know how the graph is loaded or how A* works.

They only need to request a route and display the result.

For me, this was an important architectural improvement.

It also means the routing library can evolve independently from the interface.


6. Multiple Vehicle Profiles Without Duplicating the Entire Graph

Another interesting challenge was supporting multiple types of vehicles.

A road that's accessible to a motorcycle might have different restrictions for a truck.

Even when two vehicles can use the same road, they might have different speed assumptions.

roadgraph-router-v2 handles these differences through routing profiles.

For example:

import { RoadRouter } from "roadgraph-router-v2";

const router = await RoadRouter.open(
  "/routing/portugal.efrb"
);

console.log(router.profiles);

// ["car", "truck", "motorcycle"]
Enter fullscreen mode Exit fullscreen mode

We can then select another vehicle profile.

const truckRouter = router.withProfile("truck");

const result = await truckRouter.route(
  origin,
  destination
);

console.log(result.distanceMeters);
Enter fullscreen mode Exit fullscreen mode

The important detail is that multiple motor-vehicle profiles can share road geometry and topology inside the compiled bundle.

Each profile adds the information needed to evaluate its own routing rules.

This avoids storing an entirely separate road network for every supported vehicle.

How Energy&Fuel handles this

In Energy&Fuel, I cache the routers associated with different profiles.

When a user changes vehicle type, the application can reuse a previously initialized router instead of recreating it unnecessarily.

The worker maintains a cache using a Map:

const byProfile = new Map<string, RoadRouter>();

function routerFor(
  profile: string | null
): RoadRouter {
  if (!base) {
    throw new Error("Router not initialized");
  }

  if (
    !profile ||
    base.profile === profile ||
    !base.profiles.includes(profile)
  ) {
    return base;
  }

  const cached = byProfile.get(profile);

  if (cached) {
    return cached;
  }

  const router = base.withProfile(profile);

  byProfile.set(profile, router);

  return router;
}
Enter fullscreen mode Exit fullscreen mode

This is particularly useful when users switch between vehicle types while comparing routes.

It also illustrates one of the benefits of designing the library around reusable graph data.


7. Alternative Routes and Route Preferences

Not every journey has one ideal route.

Sometimes users want the fastest option.

Other times, they might prefer the shortest distance.

roadgraph-router-v2 exposes route preferences through its API.

For example:

const fastest = await router.route(
  origin,
  destination,
  "fastest"
);

const shortest = await router.route(
  origin,
  destination,
  "shortest"
);
Enter fullscreen mode Exit fullscreen mode

The engine also supports alternative-route calculation.

const alternatives = await router.routeAlternatives(
  origin,
  destination,
  "fastest",
  {
    count: 3
  }
);

for (const route of alternatives) {
  console.log({
    distance: route.distanceMeters,
    duration: route.durationSeconds
  });
}
Enter fullscreen mode Exit fullscreen mode

This gives applications the ability to present multiple journey options.

In Energy&Fuel, the routing client supports both normal routes and alternative routes through the same worker interface.

An important distinction is that these are routes based on the compiled network and available routing rules.

They do not represent live traffic predictions.


8. What About Performance?

Performance was one of the reasons I decided to investigate compact routing graphs.

A routing engine that works well on a small city graph might behave very differently when processing a national road network.

The library therefore needs to consider more than just the pathfinding algorithm.

Three areas are particularly important.

Graph representation

The compiler transforms OpenStreetMap data into a more suitable representation for routing.

This means the browser doesn't need to parse raw OSM data whenever a route is requested.

Selective loading

The routing bundle supports HTTP Range requests.

This allows the browser to retrieve relevant sections instead of transferring the entire file.

It reduces initial data transfer, although the amount downloaded depends on the route and the graph.

Reusing loaded data

Previously downloaded and decoded graph data can be reused.

This matters when users calculate multiple routes or change vehicle profiles.

In Energy&Fuel, the worker architecture allows the router to remain initialized between requests.

That reduces unnecessary repeated setup work.

It's also worth distinguishing calculation time from user-perceived latency.

A route may require network transfers, graph decoding, and pathfinding. Measuring only the A* search doesn't tell the full story.


9. Limitations and Lessons Learned

Building a routing engine has been an opportunity to learn more about geographic data structures, graph algorithms, and browser performance.

But an equally important part of the project has been understanding its limitations.

OpenStreetMap data isn't perfect

The quality of calculated routes depends heavily on the underlying geographic data.

Some access restrictions, vehicle limitations, and road attributes may be missing or outdated.

Truck routing requires special care

Although roadgraph-router-v2 supports truck profiles and certain vehicle dimensions, a calculated route isn't a guarantee that a heavy vehicle can legally or physically travel along it.

Not all bridge clearances, weight restrictions, or local regulations are fully represented in OpenStreetMap.

The library shouldn't be the sole routing system used for safety-critical freight operations.

The graph needs updating

A compiled routing bundle represents the OpenStreetMap data available when it was generated.

It doesn't automatically know about newly opened roads, roadworks, or temporary closures.

Updating the geographic dataset requires compiling a new bundle.

Offline routing requires preparation

The engine doesn't require a routing API, but it still needs access to the compiled graph.

In a normal web deployment, that graph can be downloaded from a static server.

Fully offline operation requires the necessary data to be available locally or cached beforehand.

Existing routing engines remain valuable

OSRM, GraphHopper, and Valhalla are mature routing solutions with capabilities that roadgraph-router-v2 doesn't attempt to reproduce.

For applications requiring large-scale routing services, live data integration, or advanced routing functionality, those solutions may be more appropriate.

My goal was different: build a lightweight, reusable engine that could operate directly over compact OpenStreetMap graphs in JavaScript environments.


10. From a Project Requirement to an Open-Source Library

One of the most rewarding aspects of this project is that roadgraph-router-v2 didn't begin as an isolated experiment.

It came from a practical requirement in another application.

Energy&Fuel needed routing.

Instead of making the routing logic specific to Energy&Fuel, I separated it into a reusable package.

That decision made it possible to develop and test the routing engine independently.

It also opened the door to features that go beyond the original application.

The package now supports multiple road vehicle profiles, pedestrian and bicycle routing, alternative routes, turn restrictions, and routing over railway infrastructure.

Not every capability is used by Energy&Fuel.

And that's fine.

The application benefits from the routing engine, while the routing engine can continue evolving as a separate open-source project.


11. Try It Yourself

If you're interested in experimenting with OpenStreetMap routing in TypeScript, you can install roadgraph-router-v2 directly from npm.

npm install roadgraph-router-v2
Enter fullscreen mode Exit fullscreen mode

Compile an OSM extract:

npx roadgraph-compile monaco.osm.pbf \
  --output monaco.efrb
Enter fullscreen mode Exit fullscreen mode

Then calculate a route:

import { RoadRouter } from "roadgraph-router-v2";

const router = await RoadRouter.open(
  "./monaco.efrb"
);

const result = await router.route(
  { lat: 43.7384, lng: 7.4246 },
  { lat: 43.7325, lng: 7.4189 }
);

console.log(result.distanceMeters);
console.log(result.durationSeconds);
console.log(result.geometry);
Enter fullscreen mode Exit fullscreen mode

For more advanced functionality, the GitHub repository contains documentation covering vehicle profiles, graph compilation, alternative routes, and additional routing options.

Project links

Routing engine

Application


Final Thoughts

Building Energy&Fuel reminded me that seemingly simple application features can involve interesting engineering challenges.

Displaying a location on a map is relatively straightforward.

Calculating a route through thousands of interconnected roads, while minimizing data transfer and keeping a browser responsive, is a different problem.

Working on roadgraph-router-v2 gave me the opportunity to explore graph algorithms, geographic data processing, binary data formats, caching strategies, and browser threading.

It also reinforced an architectural principle I value:

A solution developed for one application can become a useful

Top comments (0)