---
title: "lleida buses in real time: how moventis lleida works"
description: "moventis lleida tracks lleida's buses in real time. what's wrong with the moventis api, how the stops sync every night, and why the whole network got deleted."
date: 2026-10-08
author: "joel taylor pedrós"
lang: en
url: https://joeltaylor.business/blog/lleida-buses-real-time
translations:
  ca: https://joeltaylor.business/blog/moventis-lleida
  es: https://joeltaylor.business/blog/autobuses-lleida-tiempo-real
image: https://joeltaylor.business/_next/static/media/cover.en.21nuit8ca5w-y.webp
---

# lleida buses in real time: how moventis lleida works

![a grid of 10,556 small grey squares, one per row of the moventis lines file. only 282, in blue, belong to lleida's city lines.](https://joeltaylor.business/_next/static/media/cover.en.21nuit8ca5w-y.webp)

[moventis lleida](https://moventis-lleida.joeltaylor.business) is a site for following lleida's buses in real time: the 14 city lines, how long until the next bus at each stop, and roughly where each bus is. the first version dates from october 2025.

the official moventis site already shows arrival times, but it's very slow, and the reason is a file. every time you open it, your phone downloads every line moventis runs, from pamplona to girona. a year later nothing has changed, and on 8 october 2026 i downloaded it again to get exact numbers.

## the moventis lines file

when you open the real-time page on moventis.es, its javascript requests the whole of `/es/moventis/es/lines` and then looks for the day's lines in it with `this.where({ID_GRUPO: id_grupo[i], ID_ZONA: zona, DIAS_QUE_CIRCULA: data_aux})`. the filtering happens on your phone.

on 8 october that json had 10,556 rows and weighed 3.3 mb. pretty-printed, it's 190,010 lines of text. of the 10,556 rows, 282 belong to lleida's 14 city lines, 2.7%. the rest are pamplona, the vallès, the maresme, the costa brava, the llobregat and the intercity lines.

it travels compressed, at 55 kb. that gzip can shrink it 60 times already tells you what's inside, the same text repeated thousands of times. your phone still has to decompress it and read all of it to find its 282 rows.

## one row per day

the api doesn't tell you that line 7 runs today. it repeats line 7 once for every day it will run. this is one of its 25 rows:

```json
{
  "nid": "86618",
  "ID_LINEA": "135",
  "COD_LINEA": "7",
  "DESC_LINEA": "COSTA MANGRANERS-AV.SANT PERE",
  "ID_ZONA": "2",
  "ID_SUBZONA": "1",
  "TREAL": "S",
  "FORMA": "cuadrado",
  "COLOR": "#099496",
  "TEXT_COLOR": null,
  "ID_EXPLOTADORA": "5",
  "ID_CONCESION": "1",
  "ID_GRUPO": "1",
  "ADAPTADA": "S",
  "MARCA": "86862",
  "DIAS_QUE_CIRCULA": "20261006"
}
```

across the 25 rows only the last field changes, the date, which runs from 6 to 30 october. the whole file works the same way, with dates up to two months ahead. and the 45 lines that belong to more than one zone or subzone appear again for each of them.

what isn't repeated is messy. names use underscores for spaces and stray accents for apostrophes, and some accents are missing. the code fixes 12 words by hand, among them "atlestisme", a typo in the api itself. line 70 has a route called `GARRIGUESBLOCSLACAIXA-ANTONIVILAPLANA`, and there's a stop called `20133 RICARD VIÑES/SANITAT`, with a number stuck to the front.

## how the lines and stops are synced

i built the first version in under 48 hours, in october 2025. i extracted lleida's 11 lines and 184 stops into a json, loaded them into postgres and built the interface with next.js and tailwind. arrival times are never stored. every time you open a stop, the server asks moventis live.

in march i added the missing lines, the n1, the 16 and the 70, plus an n8n flow that refreshed the database every 48 hours. since june that job has lived in the same repository, as a typescript service that runs every night. it does this:

1. downloads the file and keeps the rows for the lleida zone, plus those for any line it already has stored, in case moventis moves it to another zone.
2. drops the intercity lines and the tourist bus, which are the 120, 121, 122, 401 and bt.
3. groups the rows by line. the repeated dates stop being rows and become the line's calendar.
4. picks a weekday, a saturday and a sunday from each calendar and asks `GetTrayectos` for their routes. for 8 october that comes to 32 requests.
5. merges the variants from the three days and stores each stop keyed by its moventis id.

![six-step diagram. 10,556 rows in the lines file, 360 in the lleida zone, 282 without intercity lines or the tourist bus, 14 lines once grouped, 32 route requests and 249 stops stored by their id.](https://joeltaylor.business/_next/static/media/sincronitzacio.en.3n1i59sad0xta.webp)

_what's left at each step of the sync, redone with the data from 8 october 2026._

step 5 is what holds it all together. of the 184 stops in the first version, 183 still exist a year later with the same id. 10 have been renamed. six that carried the bus station's name now carry plaça espanya's, and "magraners" has become "mangraners". the coordinate for the hospital de santa maria stop has moved 135 metres. if the key were the name, every change would be a new stop, and shared links like `?stop=10242` would stop working.

a line's stop list is only replaced once the whole line has synced, with every request answered. if one fails, the service adds the stops it saw and removes none, because a stop that's missing after a network error isn't a retired stop.

## when the sync deleted the network

on 2 august 2026, moventis dropped the lleida zone from the lines file. the routes and times for each line were still served as usual. they had only vanished from the listing.

the sync found zero lines, and that exposed a bug. when it finishes, the code marks as deleted everything the run didn't see, with a prisma `notIn`. given an empty list, prisma doesn't match no rows, it matches all of them. three nights in a row the log said everything had gone fine while the whole network was being deleted. the 28-day purge would have removed every stop for good on 30 august.

i fixed it on 4 august. the first piece is a check before deleting anything, shortened here:

```ts
// MIN_SEEN_STOP_RATIO = 0.5
if (discoveredLines === 0) return { safe: false, reason: "no lines were discovered" };
if (incompleteLines > 0) return { safe: false, reason: `${incompleteLines} line(s) synced incompletely` };
if (seenStopCount === 0) return { safe: false, reason: "the run upserted no stops" };
if (seenStopCount < knownStopCount * MIN_SEEN_STOP_RATIO) return { safe: false, reason: "under the 50% floor" };
return { safe: true };
```

the second is a fallback for when the file doesn't include lleida. the sync starts from the lines it already has stored and rebuilds each calendar day by day with `GetTrayectos`, which for a day without service only answers `[{ numLinea }]`. it also tells paused lines apart from retired ones. line 10 doesn't run at all in august and comes back in september, and without that distinction every summer would declare it dead.

the fallback didn't work first time. the project's prisma client adds `deletedAt: null` to every route query, so the query meant to find the deleted routes and restore them only saw the live ones, and there were none. the new check did its job and touched nothing, but the network was still down. half an hour later i fixed it with an explicit `deletedAt: undefined`.

on 4 october lleida was back in the file, and on the 8th it was still there, with 18 lines. the code no longer believes a line has stopped existing just because it isn't listed.

## arrival times and bus positions

the site has the 14 lines, live times for every stop and the approximate position of each bus. moventis publishes no gps and no vehicle ids, only each stop's list of arrivals. to place a bus, the code looks for it between two stops. if it appears in stop b's list and not in the list for the stop before it, a, then it's between a and b. one pass of about eight stops per route is enough, plus up to eight more requests to narrow down the stretches with buses on them.

the first locator projected the final stop's times backwards at a fixed speed. on a circular line, though, the final stop is also the first, and the times shown there are future departures. it ended up drawing three buses nose to tail on passeig de ronda while the stop said the first one was 5 minutes away and the next 26.

every request to moventis goes through a queue of 5 per second shared by all users. if someone turned on prediction for all 14 lines at once, that would be 250 to 500 requests every 25 seconds, and the queue would never empty. so only the last three lines selected get predictions, and background requests, like the next bus shown at each stop on the map, go through a low-priority lane. with the queue full, opening a stop takes 3.1 seconds at the median. with it empty, 2.4.

![screenshot of the site on a phone over the map of lleida, with lines 5 and 9 selected, their routes drawn in green and black and the stops marked with a bus icon.](https://joeltaylor.business/_next/static/media/mapa.3v_kqze3n68ip.webp)

_the site with lines 5 and 9 selected, 75 stops between them._

## lleida bus lines and stops

with the routes for 8 october, lleida's city network has 249 stops. line 7 is the longest, with 71 distinct stops across both directions. the n1, the night bus, has 66, and line 1, the shortest, 13.

129 stops are served by a single line. at the other end, plaça espanya/saracibar and plaça espanya/pont universitat have seven each.

every night, the sync keeps 282 rows of the file and throws the rest away. the official site still requests all 10,556 every time someone opens it.
