Real-time public transit tracker for Lviv, Ukraine. Pick bus, trolleybus and tram routes on a Google Map and watch the vehicles move live, refreshed every 5 seconds.
Live demo - no install required. (Hosted on Render's free tier, so the first request after a period of inactivity may take a few seconds to wake the server.)
- Features
- How it works
- Tech stack
- Quick start
- Configuration
- Running tests
- Deployment
- Troubleshooting
- Project structure
- Roadmap and known limitations
- Background
- Contributing
- Security
- License
- Author
- Live vehicle positions on a Google Map, refreshed every 5 seconds
- Track many routes at once; each route gets its own color
- Route search/filter in the sidebar
- Smooth marker animation between position updates
- Auto-reconnect: re-subscribes your selected routes after a network drop
- Mobile sidebar with native horizontal-swipe gestures
- No jQuery, no Bootstrap; vanilla ES modules bundled by Vite
The server fetches the available route list from api.lad.lviv.ua and renders it
into the sidebar. When you check a route, the browser emits a Socket.IO event. The
server sends the route path (which the browser draws on the map), then polls live
vehicle positions every 5 seconds and pushes each update to all subscribed clients.
Markers animate smoothly as positions change.
1. Browser checkbox toggle -> emit route:subscribe(routeCode)
2. Server GET /routes/static/:name -> emit route:path { routeCode, path }
3. Server every 5s: GET /routes/dynamic/:name -> emit vehicles:update(vehicles, routeCode)
4. Browser animate markers on the Google Map
A few things worth knowing about the implementation:
- Self-scheduling poll loop. The 5s loop schedules the next cycle only after
the current one settles (not a bare
setInterval), and fetches every active route concurrently withPromise.allSettled, so a slow upstream can never stack overlapping cycles and one failing route never sinks the batch. - Socket.IO rooms. Each route code is a room; a vehicle update is encoded once
and fanned out to every subscriber via
io.to(routeCode).emit(...). - Resilient client. On every (re)connect the browser clears stale map state and re-subscribes the checked routes, because the server tracks subscriptions per socket and purges them on disconnect.
- Hardening. helmet security headers, per-socket subscribe rate limiting and route-code validation, restricted Socket.IO CORS, and a graceful SIGTERM drain for zero-downtime Render deploys.
HTTP endpoints: GET / (map), GET /json (route list), GET /healthz (liveness),
GET /about, GET /contact.
- Node.js 20+, Express 5
- Socket.IO 4 (server + client)
- EJS 6 templates
- Vite 8 (frontend bundling; vanilla ES modules)
- Google Maps JavaScript API
- helmet (security headers), compression (gzip)
- Hand-written CSS (no Bootstrap/jQuery/Hammer)
- dotenv (environment config)
git clone https://github.com/tegos/lviv-transit-tracker.git
cd lviv-transit-tracker
npm install
cp .env.example .envSet your Google Maps key in .env (see Configuration), then:
npm startnpm start runs the Vite build first (prestart), then boots the server. Open
http://localhost:3000 and check any route in the sidebar
to track it on the map. For frontend iteration, npm run dev rebuilds the bundle
on change.
Configuration is read from environment variables (see .env.example):
| Variable | Default | Description |
|---|---|---|
PORT |
3000 |
HTTP port |
POLL_INTERVAL_MS |
5000 |
Vehicle-position polling interval in milliseconds |
GOOGLE_MAPS_KEY |
- | Google Maps JavaScript API key |
ALLOWED_ORIGINS |
- | Comma-separated browser origins allowed to open a Socket.IO connection. Empty falls back to the production URL in production and localhost in dev |
API_BASE_URL |
https://api.lad.lviv.ua |
Base URL of the upstream Lviv transit REST API |
NODE_ENV |
- | production enables the production CORS default and hides error detail |
npm test # unit + socket integration (offline, model stubbed)
npm run test:coverage # same suite with a coverage report
INTEGRATION=1 npm test # also hit the live api.lad.lviv.ua
npm run test:e2e # Playwright browser smoke testsCI runs the Vite build, the test suite on Node 20/22/24, and npm audit.
The app deploys on Render as a web service (free plan) from
the committed render.yaml blueprint:
- Build:
npm ci && npm run build - Start:
node ./bin/www - Runtime pinned to
NODE_VERSION=22 - Health check:
GET /healthz(a no-I/O liveness probe, so it stays green even if the upstream transit API is down)
Set GOOGLE_MAPS_KEY in the Render dashboard (it is sync: false, never committed).
Custom domain / fork note: Socket.IO only accepts connections from allowed origins. In production this defaults to
https://lviv-transit-tracker.onrender.com. If you fork, rename the service, or attach a custom domain, setALLOWED_ORIGINS=https://your-domain(comma-separated) or the live socket connection will be rejected.
- Blank or grey map on localhost. The Google Maps key is referrer-restricted in
the Google Cloud Console. Add
http://localhost:3000to the key's allowed referrers, or the Maps JavaScript API fails silently. (This is also why a strict Content-Security-Policy is currently disabled; it cannot be verified locally without a working key.) - Map loads but no vehicles, or an error page. Either
GOOGLE_MAPS_KEYis unset (the Maps script is never injected) or the upstreamapi.lad.lviv.uais down (the home route then returns502; the route list is served stale from cache when one is available). - Checking a route does nothing. The subscribe is acked with
{ ok: false }for an unknown route code, or when you exceed the per-socket rate limit (30 subscribes / 10s). Check the browser console. - Updates stop after a reconnect. The client auto-resubscribes on reconnect; if
it stays stuck, the browser origin is likely not in
ALLOWED_ORIGINS(see Deployment).
app.js - Express app entry (helmet, compression, routes, error handling)
bin/www - HTTP server bootstrap + graceful shutdown
config/ - Environment/config loader (dotenv)
routes/ - Express route handlers (/, /json, /healthz, /about, /contact)
models/ - Upstream API client (fetch wrapper with timeout)
services/ - Shared route-list TTL cache + valid-code lookup
socket/ - Socket.IO event handlers, poll loop, rooms, event-name enum
src/ - Frontend ES modules (bundled by Vite into public/dist)
utils/ - Upstream API URL builders
views/ - EJS templates
public/ - Static assets (CSS, favicons); public/dist holds the built bundle
test/ - Node built-in test runner specs
e2e/ - Playwright browser smoke tests
google.maps.Markerwas deprecated by Google in February 2024 in favor ofAdvancedMarkerElement; the app still uses the classic marker. Migration is a known follow-up.- A Maps-tuned Content-Security-Policy is not yet enabled (see Troubleshooting).
- Render's free tier sleeps on inactivity, so the live demo has a cold start.
- Single upstream data source; if
api.lad.lviv.uais down there is no live data, though the cached route list is served stale where possible.
Built in 2017 against the SimpleRIDE API provided by the Lviv transit authority. That API went offline in July 2018 and stayed down for years. In 2026 the project was modernized (Node.js 20+, Express 5, Socket.IO 4, Vite) and migrated to api.lad.lviv.ua, an open, live JSON REST API for Lviv public transit. Live bus data works again.
See CONTRIBUTING.md for setup, the local checks to run, and the branch/PR workflow. In short:
- Fork the repo and create a feature branch (
git checkout -b feat/my-feature) - Make your changes and add tests where relevant
- Run
npm testandnpm run build - Push the branch and open a pull request
By participating you agree to the Code of Conduct.
Please report vulnerabilities privately as described in SECURITY.md rather than opening a public issue. Note that the Google Maps key is necessarily exposed to the browser and is protected by referrer/API restrictions, not secrecy.
MIT - see LICENSE for details.