More Projects
59 in total
Smart Savaari
Founding Engineer · FA Smart Savaari Mobility Pvt. Ltd. · 2023
Delhi public-transport journey planner. A Flutter app (Firebase auth, Google Maps) sits over a Node/Express service that ingests the DTC/DIMTS bus and DMRC metro GTFS feeds into SQLite. Most journeys are answered by one self-join over stop-times — which vehicles pass your origin and later pass your destination — without materialising a graph at all; a separate Dijkstra endpoint covers transfers, with Redis caching the corridors that get asked for repeatedly.
10 buses · 500+ stops
DPIIT-recognised startup (DIPP169676) — incubated at STEP-TIET under MeitY
Project Details
Founding Engineer
FA Smart Savaari Mobility Pvt. Ltd.
2023
10 buses · 500+ stops
DPIIT-recognised startup (DIPP169676) — incubated at STEP-TIET under MeitY
Product · Mobile
Smart Savaari was a mobility startup in Delhi. I joined as founding engineer and built both halves of the product, the Flutter app and the Node backend behind it, while we were running on a ₹4 lakh grant from the Ministry of Electronics and IT and a team of five interns. The company was DPIIT-recognised and incubated at STEP-TIET.
The problem
Delhi's public transport data exists, but not in a form a commuter can act on. The GTFS feeds from DTC, DIMTS and DMRC together describe some 11,000 stops and 3,500 routes; what they don't tell you is which combination of them gets you from where you are to where you're going, at this time of day, for the least money.
The naive version of this is a graph problem: load every stop as a node, every consecutive pair of stops on a trip as an edge, run a shortest path. That is the textbook answer and it falls over on the actual data. The bus feed alone has 5.3 million stop-time rows across 131,997 trips. Materialising that as an in-memory adjacency map means holding several million edges per process, rebuilding them whenever the feed updates, and paying that cost in every one of the worker processes the server forks. Worse, it is the wrong shape for the common case: most Delhi journeys that a commuter would actually accept are a single bus, and answering "is there one bus from near me to near you" does not need a graph at all. That observation is what the whole backend is organised around.
What it does
A journey planner. The backend ingests the GTFS feeds into SQLite and answers most queries straight out of the stop-times table, with a separate graph search reserved for transfers:
- Route search over the combined multi-operator network.
- Fare and time calculation on every candidate itinerary, from the feed's own fare rules rather than a flat estimate.
- Redis caching in front of the search, because the same corridors get queried constantly and the underlying data changes on a timetable, not per-request.
- Live vehicle positions for the buses we operated directly.
The mobile side is Flutter, talking to that API, with Firebase for auth and messaging and a local cache so a journey you've already looked up survives a dead signal.
Getting the feeds in
Ingest is declarative. config.json names two agencies: our own hosted bus bundle under
the dtc_ prefix, and the DMRC metro bundle under dmrc_. The prefix is the whole trick
for multi-operator data, because a stop called 101 exists in both feeds and means two
different places. Prefixing at import time means a stop ID is globally unique from then on
and the routing code never has to carry an operator column alongside every key.
loadGTFSData checks whether the SQLite file already exists. If it does, it only pulls the
realtime feed; if it does not, it runs the full importGtfs, which is the expensive path
and the reason the master process does the load before forking anything. The bus side lands
11,204 stops, 3,507 routes, 131,997 trips and 5,317,023 stop-time rows, plus 3,015,769 fare
rules and the matching fare attributes, which is what pushes the database past 1.3 GB. The
metro side adds 262 stations, 36 route patterns and 5,438 trips.
A self-join over five million rows is only viable with the right indexes, and those are the
five CREATE INDEX statements sitting commented out at the top of route.controller.js on
stop_times(stop_id), stop_times(stop_sequence), routes(route_id), trips(trip_id) and
fare_rules(fare_id). They were run once by uncommenting them against the built database
and then left in place as documentation of what the query needs.
Two routing paths, deliberately
The direct search is a single SQL statement. query_get_routes_between self-joins
stop_times against itself on trip_id, constrained so the origin's stop sequence is lower
than the destination's, with both ends supplied as IN lists of candidate stop IDs, grouped
by trip so each trip contributes one row. That one query answers "which vehicles pass my
origin and later pass my destination" without materialising a graph at all, and it is what
serves the overwhelming majority of queries.
Transfers need something else, and that is a hand-written Dijkstra over an adjacency map in
services/routeService.js, with edge weights computed as the arrival-time difference
between the two stops in minutes. The graph it walks is built from the same self-join
result, so the expensive part is still SQL and the graph only ever holds the segments
relevant to this query rather than the whole network. Being honest about the wiring: this
path is not an automatic fallback. It is mounted on its own endpoint, and the direct search
never falls through to it. Finishing that handoff is one of the things the repo ends before
reaching.
Candidate stops come from findNearestStops, which pulls every stop for the requested
operator, computes a haversine distance in JS, discards anything past 3 km, sorts and takes
the closest count. The search starts at 5 stops per end and widens in steps of 5 up to 20
before giving up, which is what stops a commuter standing between two corridors from getting
an empty result.
Inside the backend
- Two feeds under one schema. Both import through
gtfs@4.10.2under agency prefixes so a stop ID is never ambiguous, into a SQLite database that reaches 1.3 GB behind 5.3 million stop-time rows. - Fare from the feed, not from a guess. Each candidate route resolves its fare rule by route, origin and destination, then reads the price off the matching fare attribute. When no rule matches, the fare falls back to a flat 5 rupees rather than returning null, so the card always renders.
- Route names cleaned at the edge. Delhi's route long names carry a trailing
UPorDOWNfor direction, which is meaningful to an operator and noise to a commuter, so it is stripped before the response goes out. - Google Distance Matrix in walking mode for the first and last leg, on a one-second timeout, because a slow third party must not become a slow search. A failed call returns nulls rather than throwing, so the itinerary survives the walking leg being unavailable.
- Three cache families in Redis, keyed on the route corridor, the nearest-stop lookup and the walking-time call, which are what repeat most across users.
- Clustered workers, one process per CPU forked after the GTFS load completes, so the expensive import happens once and every worker serves the same database, behind a global limit of 100 requests per fifteen minutes.
- A maintenance mode and health check the app polls on launch, so a bad deploy shows a banner instead of an empty screen.
- Realtime ingestion pulling the Delhi Open Transit Data vehicle-position feed into the store the timetable lives in.
- Timestamped logging via a monkeypatched
console.logthat tees every line intologs/logs.txtwhile still writing to stdout. - Docker Compose with Redis in the image, deployed by a GitHub Action that SSHes into the box and rebuilds, which was the whole of our infrastructure.
- Account deletion and privacy pages served by the API itself, because the Play Store requires them and we were not standing up a host for two static files.
The API surface is eight routes: login, the privacy policy and account-deletion pages, the
account-deletion POST, findRoute and getRoute (the same handler under two names, because
the shipped app calls one and later work used the other), getStops for the launch screen,
the health check, and the transfer-routing endpoint.
The workers that got reverted
There are three files under controllers/ named worker.*.js, written against
worker_threads to push fare lookup, distance summing and the whole route search off the
request thread. Every new Worker(...) call site in the controller is commented out and the
work runs inline. The reason is visible in worker.calculate.fare.js: it expects the gtfs
module itself to arrive through workerData, and workerData is structured-cloned, so a
module namespace with functions on it cannot cross that boundary. The fix would have been to
re-import gtfs and reopen the database inside each worker, which for a query that already
returns in milliseconds against an indexed SQLite file buys nothing and costs a database
handle per thread. Reverting to inline was correct. Leaving the files in the tree was not,
and this write-up is the first time that has been said out loud.
The app
About 6,500 lines of Dart across forty files, shipping at version 3.2.3 build 22. A splash screen gates permissions and runs a three-stage launch check — internet reachability, then the server health check, then the maintenance flag — before the UI commits to anything. Then a four-step onboarding covering Google sign-in and phone OTP, place search with Places autocomplete, a map drawing the route as a polyline, and separate card stacks for buses, metro legs and intermediate stops. Firebase supplies auth, messaging, analytics and in-app messaging; the FCM token goes up with login. If location permission is refused, the app falls back to the centre of Delhi rather than blocking.
Persistence is shared_preferences and flutter_secure_storage — recent searches, the last
known location, the session. There is no local database on the device; the 1.3 GB SQLite
file is entirely server-side, which is the only place it could live.
Honest edges
Auth is thinner than it looks. Firebase authenticates the user on the device, and the client
then posts the resulting profile and UID to /login, which upserts a session document. The
server never verifies the Firebase ID token, so it trusts whatever UID the body carries.
That is fine for a five-person startup shipping a read-only journey planner and would be the
first thing to fix before anything is written on a user's behalf. Account deletion is a soft
delete, flipping a status field rather than removing the row.
The Redis keys are string concatenations of coordinates with no separators and no TTL, so they never expire and two different coordinate pairs can in principle produce the same key. The maintenance middleware compares an environment variable against a boolean, which is never true for a string, so the actual gate is the flag the app reads out of the health check rather than the server-side block. The log directory is served as a static route. Each request opens its own database handle and never closes it. None of these mattered at ten buses; all of them are the difference between a working service and an operable one.
Where it went
The Flutter app carries 95 commits between November 2023 and April 2024, the routing backend another 48 between April and July 2024, ending on direct metro routing. An earlier backend attempt exists too, scaffolded and abandoned at a single stub route, which is what made the rewrite worth doing. We operated 10 buses across 500+ stops. The company has since wound down and smartsavaari.com no longer resolves; this entry is a record of the work, not a link to a running product.
Project Details
Founding Engineer
FA Smart Savaari Mobility Pvt. Ltd.
2023
10 buses · 500+ stops
DPIIT-recognised startup (DIPP169676) — incubated at STEP-TIET under MeitY
Product · Mobile