Article

Building a Real-Time Transit App Solo

What I learned designing and shipping NavEire, a full-stack public transport tracker for Ireland, from architecture decisions to production deployment.

10 min read
Building in PublicFull-StackReal-Time Systems

The Problem That Started It

Ireland has open public transport data. The NTA publishes GTFS static feeds and real-time vehicle positions, trip updates, and service alerts. But the tools available to riders have gaps, particularly if you want a map-first experience that shows you what is actually happening right now across bus, rail, DART, and Luas in one place.

I started NavEire because I wanted to use it myself. That turned out to be the most honest form of validation I have done on any project: I was the user, and I knew exactly what frustrated me. The initial version was a map with colour-coded stop markers and live vehicle positions. It is now a full journey planner with route reliability grading, service alerts, favourites, and an installable PWA.

Why SQLite Over Postgres

The first real architecture decision was the database. NavEire is a read-heavy application with a single server instance. The entire GTFS dataset fits comfortably in a single SQLite file running in WAL mode via better-sqlite3. Stops, routes, trips, stop_times, shapes, calendar data. All in one file.

This choice kept the stack simple and the hosting cheap. There is no connection pool to manage, no separate database server to pay for, and reads are extremely fast with the right indexes. The tradeoff is that SQLite is not ideal for concurrent writes, but NavEire barely writes at runtime. The database is rebuilt in CI when the NTA publishes a new GTFS version, compressed with gzip, and downloaded by the server on cold start.

That pipeline was one of the more satisfying pieces of engineering in the project. Build in CI, compress, publish as a GitHub Release, download on boot. The server calls a health endpoint, the frontend shows a startup banner until the database is ready, and the whole cold start takes about thirty seconds.

The Stop ID Problem Nobody Warns You About

The single most frustrating technical challenge was stop ID reconciliation. The NTA's static GTFS feed uses one set of stop IDs. The real-time feed uses a different set. The same physical stop has two different identifiers depending on which feed you are reading.

The solution was a stop_id_map table built by coordinate matching. Find stops in both feeds that are within a close radius of each other and map them together. It sounds simple, but edge cases are everywhere: stops that moved slightly between feed versions, stops that exist in one feed but not the other, and duplicate matches where multiple candidates fall within the threshold.

This is the kind of hidden complexity that does not show up in any architecture diagram but consumes days of debugging. If I were advising someone starting a similar project, I would say: budget twice as much time for data reconciliation as you think you need.

Progressive Streaming for the Journey Planner

The journey planner was the most ambitious feature. Multi-modal planning across bus, rail, DART, and Luas with transfers, accessibility filtering, and depart-at or arrive-by modes. The naive approach would be to compute all results and return them in one response, but for complex city-to-city queries, that can take many seconds.

Instead, I implemented progressive streaming using NDJSON. Results are sent to the client as they are found. The frontend renders journeys incrementally, so the user sees options appearing within a second or two even if the full computation takes much longer. Expensive city-to-city requests are offloaded to a worker thread to avoid blocking the main event loop.

Under heavy load, the system uses admission control: bounded concurrency, a queue with a maximum depth, and explicit 503 responses with Retry-After headers when capacity is exhausted. The philosophy is that it is better to refuse a request cleanly than to let everything degrade. A stress test firing ten concurrent city-to-city queries should return a mix of 200s and 503s, not widespread timeouts.

What I Would Do Differently

Observability from day one. I added reliability tracking and route grading later in the project, but I wish I had instrumented user behaviour from the start. Understanding which stops people search for, which routes they check, and where they drop off would have changed my feature priorities.

I also underestimated how much the data pipeline would dominate the project. The user-facing features are visible and rewarding to build. The map, the departure board, the planner UI. But the GTFS import scripts, stop ID reconciliation, shape subsampling, and cache invalidation logic are invisible and account for a huge share of the total effort. The lesson is old but worth repeating: the boring infrastructure work is what makes the interesting features possible.

Related Articles

6 min read

Shipping Solo: What to Polish and What to Skip

When you are building alone with 10 to 20 hours a week, every decision about quality is a decision about what does not get built.

Solo BuildingDeliveryBuilding in Public
7 min read

From Game Loops to Product Loops

How the engagement loop thinking I learned designing live game content applies to the products I build now.

Product DesignGame DesignEngagement
Back to articles