My mom asked.
We spend a lot of time in Torrevieja, and registered residents ride the town's buses for free. Back then we mostly flew in, with no rental car and no car of our own, so the bus was how we got around. Every expat we knew, and plenty of locals, had the same question about it: when?
The buses themselves seemed to run on time. Maybe they even did. The problem was the schedule. It lists departure times from the terminal stops only, and anywhere in between you give it your best estimate.
So we built an app that does the estimating. It's called Cuando, which is Spanish for "when": the whole job description in one word. There's a bus in its icon, and its door is a question mark.
As of this week, Cuando is on Google Play and the App Store, with a web version for everybody else. It's free, it speaks seven languages, and it knows the town's bus lines stop by stop.
What it does

- The map shows every line and every stop. Turn all the lines on, or leave just the one you need.
- Nearest stop, recent stops, favourites, and a search that knows both stops and places.
- A stop shows the lines that call there and when the next buses arrive, per line. There's a line of small print under them: "The timetable is approximate". It's the most honest sentence in the app, and I'll explain why below.
- A line shows its whole route, stop by stop.
- Reports. "Wrong departure time" and friends, with a photo or a video if you have one. They land in our editor, right next to the data they're about.
- Seven languages: English, Spanish, Russian, German, Polish, Belarusian and Chinese. Torrevieja is that kind of town.
- Sign in with email, Apple or Google.
There was no data, so we drew it
There was nothing to import. No official feed, no timetable file a search engine could find, no API: just the buses, and those departure times at the ends of each line. So the first two things we built weren't the app at all.
The first was a stopwatch for your phone. It's a tiny web app with two buttons: press Start at a stop and Stop at the next one, and it saves the GPS trace between them and the seconds it took, as a suggested edge.
My dad and my little sister did the fieldwork: about four days, three to four hours a day, counting the trips out to where each line starts.
The second was an editor: a small Vue app on top of a map. It turns those suggestions into stops and into the edges between them. An edge is a polyline you can drag into place, plus a travel time.
Then my brother went over every line by hand, bending the polylines to follow the actual roads, curves and roundabouts. We tried snapping them to the map service's roads automatically, and it was no good. The travel time on each edge is picked as minutes and seconds, which is why every duration in our data looks like this:
stations:
- uid: c-mayor
name: C/ Mayor
address: "Calle Mayor, 59"
departure_for: [lines/a-torrevieja, lines/a-2-torrevieja]
lines:
- uid: a-torrevieja
name: A
route: La Mata - Torrevieja
color: '#FF8B37'
opposite: lines/a-la-mata
stations: [stations/c-mayor, stations/petanca, …]
route_edges:
- from: stations/c-mayor
to: stations/petanca
line: lines/a-torrevieja
duration: 01m40s
polygon: [{ latitude: …, longitude: … }, …]
Everything lives in the database while we edit, and gets exported back to YAML in git, so the network has a history and a diff like any other code.
Today that's 163 stops, 16 lines (A, A.2, B, C, DF, E, G, each in both directions, plus the airport) and 239 edges. The typical hop between two stops takes about a minute and a half.
Timetables came last, from wherever we could find them: locals who send us updates (our "agents"), media written for tourists, and schedules people post online. They came in by hand too. In mid-November only lines A, A.2, C and G had departures; B, DF and E had none at all. Then, in the first two weeks of December, a second person joined the backend and typed in the rest: 36 commits, a timetable file that grew to 280 entries, and a Cyrillic "С" smuggled into a stop name by a keyboard layout. (One of their commits is titled ашч, which is "fix" typed on a Russian keyboard. Respect.)
A timetable entry is a pattern, not a list:
- from: stations/c-mayor
line: lines/a-torrevieja
time: "07:30"
recurrence: 30m
till: "20:01"
days: [mon, tue, wed, thu, fri]
A generator expands it into concrete departures per day.
The whole trick
Here's how Cuando knows when a bus will reach your stop, straight from the README I wrote on the first night:
Going from Calle Morera to Calle Mayor 59 will take 9 minutes (3+2+2+2). From main database we know that the bus departs at 7:30 from Calle Mayor. Arrival time at Calle Morera will be 7:39.
Remember the schedule that only lists departures from the ends of each line? That's all we need from it. Everything else is that start time plus the sum of the edges in between. That's why the app says "approximate": our arithmetic is exact, but the bus is stuck behind a delivery van on Calle Mayor.
Eight weeks of ArangoDB
A bus network is a graph, so on day one it went into a graph database. ArangoDB, with the lines as a graph and the question as a traversal:
FOR node, edge, path IN 0..100 INBOUND start
GRAPH "routes" OPTIONS { uniqueVertices: "path" }
FILTER path.vertices[-1].is_departure == true
SORT LENGTH(path.edges) ASC LIMIT 1
It worked. It was also a second database, running next to the one that held everything else. In September the data layer moved to PocketBase, and the traversal became a loop in Go that asked for one hop at a time. On October 11 it became my favourite hack in the whole project. Here it is, trimmed a little:
WITH RECURSIVE dfs(station_id, departure, path, line, depth) AS (
SELECT route_edges.`from`, stations.id, "00m00s", line, 0
FROM route_edges JOIN stations ON route_edges.`from` = stations.id
WHERE is_departure = TRUE AND line = {:line}
UNION ALL
SELECT route_edges.`to`, dfs.departure,
dfs.path || ',' || route_edges.duration, route_edges.line, dfs.depth + 1
FROM dfs JOIN route_edges ON route_edges.`from` = dfs.station_id
WHERE route_edges.line = dfs.line OR dfs.line IS NULL
)
SELECT * FROM dfs WHERE station_id = {:station_to} ORDER BY depth;
A recursive CTE in plain SQLite walks the line from its first stop and collects the durations on the way. It doesn't look like much, and it took me a few days to get right. The commit that deleted ArangoDB, 618 lines of it, landed twelve seconds after the one that replaced it. It was never going to be a close vote.
Last week, arrival times stopped walking the graph altogether. A small job now precomputes, for every line, the seconds from each stop to the next as one vector. An arrival time becomes "departure plus the sum of a slice of that vector", which any database answers in no time.
One binary, one Worker, one app
The stack is deliberately small:
- The backend is one Go binary with PocketBase embedded: SQLite, an admin UI, auth, file storage and realtime, all in one process. It ships as a container image. Bus-specific endpoints like "when is the next A at this stop" sit next to the collections.
- In front of it is a Cloudflare Worker on
api.cuando.app, with a read-only copy of the data in D1, Cloudflare's SQLite. The copy is built in the simplest way I could think of. PocketBase writes backups as a zip of its SQLite file. On weekday mornings a GitHub Action takes the newest one, runssqlite3 .dumpon it, fixes it up withsedand pipes it intowrangler d1 execute. It skips all of that if the dump's hash hasn't changed. Because both sides are SQLite, the Worker even runs the same.sqlfiles as the Go server. - The app is Flutter: one codebase for Android, iOS and the web version.
- The map is Mapbox, drawn as raster tiles, for now. Back in September we did the maths and found that "we would run out of Static Tiles 200k/month requests allowance before we even get any users." It turned out to be optimistic. The three of us use up the free monthly quota with ease just testing, and we're paying Mapbox $10–30 a month for an app almost nobody has heard of yet. So the next thing we're building is a map of our own. That's part two.
Shipping
Some numbers from the last six months:
- The landing page at cuando.app said "Hello, World! ¿Cuando?" for its first ten days, which was accurate.
- The Google Play pipeline took 13 commits and about ninety minutes to build, most of them titled "attempt". Its build number is the Unix time divided by three, with a comment promising that "it should work until August 21, 2169". Play caps version codes at 2.1 billion, and we've planned accordingly.
- The week before launch went into "compliance": rewriting every permission prompt until it explained itself, and dropping background location.
- By the numbers: about 450 commits on the backend and 550 on the app. Five people have touched the app so far, most of all our Flutter developer, with more than half of its commits.
What's next
- "How do I get from A to B?" It already exists on the server as an alpha: search for routes with up to two changes, and a walk of up to 500 metres between nearby stops when that's quicker than waiting. It's written as, you guessed it, another recursive query. The app doesn't call it yet.
- Better timetables, and the reports that help us fix them.
- Our own map. See above: that's part two.
That's it
- Get it: cuando.app, with links to Google Play, the App Store and the web version.
- Tell us what's wrong: the Report button in the app, or info@cuando.app.
And if the bus still doesn't come: at least now you know it's late.
- ArangoDB never came back. One database was the right call.
- That recursive query aged badly. When route search finally served real users from D1 in late 2025, one search read about two million rows. And the "depth-first search" turned out to be breadth-first: SQLite's recursive CTEs walk a queue. The walking links multiplied everything. That's the post about routing in SQL.
- Our own map went live four weeks later. By the time we properly started telling people about Cuando, everything but the satellite view was drawn by us. That's part two.
- PocketBase carried everything until spring 2026, on a droplet with one CPU and 1 GB of RAM, and was switched off in July 2026.
- There's still no official feed that we know of. The hand-drawn network is still the source of truth, and in 2026 it became a GTFS feed for a real routing engine.
- The Flutter app is being rewritten in Swift.
- "The timetable is approximate" is still in the app, and still true.