When the new Lanka Metro Transit routes for Colombo were announced, the city was buzzing. We were getting a massive upgrade to our public transit network. But a familiar problem immediately surfaced: there was no unified, digital tool to help commuters figure out how to navigate these new lines. I wanted a journey planner. I didn't want to download a heavy app, I didn't want to create an account, and I didn't want to wait for an official release that might take months. So, I decided to build one. This is the story of how an afternoon experiment turned into a robust, single-file HTML application, the constraints that forced me to get creative, the day the architecture completely broke, and the lessons learned building a client-side routing engine.
The Problem
Colombo's new transit routes form a web of intersecting lines. Commuters need to know exactly which bus to board, where to transfer, and how to bridge the gap between their front door and the nearest halt.
The Goal
To build a web-based Journey Planner that is:
- Frictionless: No logins, no downloads.
- Fast: Immediate routing results.
- Resilient: Must work even on spotty mobile connections.
The Constraints
To keep hosting costs at exactly zero and deployment as simple as sharing a link, the entire application had to exist within a single .html file.
- No backend server (Node.js/Python).
- No spatial databases (PostGIS).
- No complex build tools (Webpack/Vite).
Everything the UI, the routing logic, the geospatial math, and the data had to run in the client's browser.
Iteration 1: The Naive Approach
My first attempt was entirely brute force. I mapped out the bus halts into a local JSON object and drew straight lines between them on a Leaflet map.
If a user wanted to go from Kottawa to Fort, the script just checked if both stops were on the same array. It worked for direct routes, but the moment someone needed to transfer, the logic fell apart. Furthermore, the map looked terrible. Buses don't fly; they follow curving roads.
Iteration 2: Chasing Perfection
To fix the visual issue, I integrated the Open Source Routing Machine (OSRM) API. Instead of straight lines, the app dynamically sent the coordinates of the bus halts to OSRM, which returned beautiful, perfectly snapped GeoJSON road paths.
For the transfer problem, I realized I needed a graph structure.
The Routing Graph
Once every route became a graph, finding a journey stopped being a map problem and became a graph traversal problem.
Makumbura ->Kottawa -> Pannipitiya -> Thalawathugoda
By connecting these nodes, a modified Dijkstra's algorithm could find the shortest path from origin to destination.
Everything looked beautiful. The code was elegant. The map looked professional.
And then...
The Day OSRM Beat Me
I loaded the app into an online code sandbox to test it on a mobile view.
The map froze. The screen went white. Then, a massive red error filled the console: “Unhandled Promise Rejection: Error: Preview stopped: a loop ran longer than 400ms – check for an infinite loop.”
Because Sri Lanka has complex one-way road systems (like Galle Road vs. Duplication Road), the inbound and outbound buses take different physical streets. To map the network accurately, the browser was firing 14 simultaneous routing requests to the OSRM server (7 routes × 2 directions) inside a for loop.
The sandbox detected this massive spike in network requests as an infinite loop and aggressively killed the application. My beautiful routing engine never even got a chance to run.
Rethinking the Architecture
I had to face a harsh reality: relying on heavy, concurrent API calls on initial page load was a terrible idea for a zero-backend app. I needed a compromise.
Version 3: Precomputed Geometry
- Fast ⚡
- Offline-First 💾
- Stable 🛡️
Instead of asking OSRM for directions on the fly, I pre-baked the complex coordinate arrays directly into the JavaScript file. For the most complex routes, the exact path was hardcoded. For simpler routes, I reverted to straight lines. It was a slight visual downgrade, but it guaranteed 100% stability and sub-second load times.
How the Final System Works
With the API nightmare solved, the architecture finally locked into place. Here are the core systems that make the journey planner tick.
1. Unified Search (Graceful Degradation)
Users rarely know the exact, official name of a bus halt. They search for landmarks. But what if they have a terrible 3G connection and the geocoding API times out?
The search input checks a local, offline dictionary of halts first, immediately returning results tagged with a HALT badge. Simultaneously, it pings the OpenStreetMap Nominatim API for addresses. If the API fails, the user can still route themselves using the local data.
2. The Transfer Graph (The "Walking" Illusion)
Real-world transfers rarely happen at the exact same GPS coordinate. You usually have to get off a bus and walk 50 meters down the street to a different platform.
To solve this, the application isn't looking for identical stops. It looks for stops within a specific radius.
I introduced a constant: var TRANSFER_M = 200;. During initialization, the app builds a graph of nearby Halts, linking any two nodes that are less than 200 meters apart. The routing algorithm treats these short walks as valid transfer points, perfectly mimicking real human transit behavior.
3. The Resilient Bootloader
Single-file HTML apps are fragile. If the CDN hosting your map library goes down, your users just see a broken white page.
To prevent this, the app uses a custom bootloader that acts like a tiny dependency manager.
The script maintains an array of three different Leaflet CDNs. It attempts to load the first one. If the onload event fails, it seamlessly tries the next one, updating the user interface with a friendly loading spinner.
Lessons Learned
- The UI is just the tip of the iceberg. Drawing a map is easy. Calculating the logic of how human beings traverse a city is incredibly difficult.
- APIs are a liability. Every time you call a third-party API from the frontend, you introduce a point of failure. Caching and pre-computing data is almost always the safer bet for resilient apps.
What I'd Do Differently in v2
Web Workers: Running a graph traversal algorithm on the main thread is fine for 100 nodes. If this expanded to thousands of nodes, it would cause UI stuttering. V2 would offload the routing engine to a Web Worker.
GTFS Integration: Right now, routing is based purely on distance. Integrating General Transit Feed Specification (GTFS) data would allow for time-based routing (e.g., "Take CM01 because the next bus arrives in 3 minutes").
Thoughts on AI Pair Programming
I built this with heavy assistance from an AI coding agent. But the reality of AI pair programming is very different from the hype.
AI wasn't writing the application for me. It was shortening the feedback loop.
When I encountered the OSRM failure, I didn't spend three hours reading documentation to figure out a fix. I bounced ideas off the agent. We tested an architectural pivot in minutes instead of hours. Most of its initial code suggestions were discarded. Some were refined. A few fundamentally changed the application.
The biggest productivity gain wasn't code generation it was rapid, fearless experimentation.
Final Architecture Summary
Here is how all the pieces finally fit together to get a commuter from their home to their office.
Enjoyed this article?