All projects

Project

ADS-B Flight Tracker

2026 · Java & Spring Boot · Personal

A real-time flight tracking application built with Java and Spring Boot. Polls live ADS-B data from the OpenSky Network every two minutes, persists aircraft positions to a PostgreSQL database on Azure, and visualizes active flights on an interactive Leaflet.js map with rotating aircraft icons and click-through popups showing altitude, speed, heading, and vertical rate.

Java 21 Spring Boot PostgreSQL Azure Docker JPA/Hibernate Leaflet.js REST API GitHub Actions

Overview

Every aircraft in controlled airspace continuously broadcasts its own position, altitude, and velocity over ADS-B — a public, unencrypted signal that anyone with a receiver can listen to. The OpenSky Network aggregates those broadcasts from thousands of volunteer receivers and exposes them through an API. This project turns that firehose into something you can actually look at: a live map of aircraft currently in the air, each icon rotated to its true heading, each one clickable for the telemetry behind it.

I built it because I wanted a project that was genuinely live rather than a static CRUD app — something with a real external data source, real persistence, and real deployment constraints. Every piece of it had to keep working while unattended: the poller has to survive an API that intermittently rate-limits, the database has to absorb a burst of writes every cycle without starving the web requests, and the whole thing has to stay up on a small Azure App Service instance. Those constraints turned out to be where all the interesting engineering was.

Architecture

Scheduled poller OpenSky API Spring Boot backend Azure PostgreSQL REST endpoints Leaflet.js frontend

A Spring @Scheduled poller fires every two minutes and requests the current state vectors from the OpenSky Network API. The Spring Boot backend maps that response onto JPA entities and writes aircraft and position records to a PostgreSQL instance hosted on Azure, upserting aircraft that are already known and appending new position rows for the ones that have moved. A set of REST endpoints exposes the most recent snapshot as JSON, and the Leaflet.js frontend fetches from those endpoints to draw the map — one marker per aircraft, rotated to its reported heading, with a popup bound to each marker carrying altitude, ground speed, heading, and vertical rate.

The whole application is containerized with Docker and deployed to Azure App Service. A GitHub Actions workflow builds the image and pushes the new revision on every merge to main, so deployment is just a merge.

Dead reckoning

The map used to redraw only when a new poll landed, so aircraft sat still between cycles and then jumped to their new positions. Dead reckoning fixed that: between polls, the frontend projects each aircraft forward from its last known ground speed and heading, advancing the marker along its track and snapping back to truth whenever fresh data arrives. The backend still polls OpenSky every two minutes, but the map refreshes every 30 seconds off those projections, so the aircraft glide the way they actually fly instead of teleporting.

Technical challenges solved

The N+1 query problem

The first version of the map endpoint loaded a list of aircraft and then lazily touched each one's positions, which made Hibernate issue one query for the list plus one more per aircraft — hundreds of round trips to satisfy a single page load. Profiling the generated SQL made the pattern obvious. I reworked the repository layer to fetch the data it actually needed in one pass via an explicit join fetch and a projection, which collapsed those hundreds of queries into a single query and took the endpoint from visibly slow to instant.

Connection pool exhaustion under load

Under concurrent requests the app would stall and then start throwing timeouts waiting for a connection. The cause was connections being held far longer than the work required — long-lived transactions spanning work that didn't need to be transactional, on a pool sized for a much smaller App Service tier. I tuned the HikariCP pool to match what the Azure PostgreSQL instance could actually support and tightened transaction boundaries so connections were borrowed for the shortest possible window and returned immediately.

Application Insights classloader conflict

Enabling the Application Insights Java agent on Azure App Service broke startup outright — the agent attaches before the Spring Boot application and its instrumentation collided with the executable JAR's nested classloader, failing in a stack trace that pointed nowhere near the actual cause. Isolating it meant reproducing the container locally and bisecting the agent configuration until the conflict was identifiable, then resolving the load order so telemetry and the application could coexist.

Batch ingestion vs. transaction monopolization

Each poll cycle brings in thousands of state vectors at once. Writing them inside one long transaction meant a single ingest could hold its connection and locks for the duration, starving the REST endpoints serving the map at exactly the moment users were looking at it. I restructured ingestion to write in bounded batches with JDBC batching enabled, so the poller commits in chunks and releases its connection between them instead of monopolizing the database for the length of the whole cycle.

What I learned

This was the project where the Spring ecosystem stopped being magic. Working with JPA/Hibernate taught me what an ORM is actually doing underneath — that an innocent-looking field access can trigger a query, that fetch strategy is a design decision rather than a default, and that reading the generated SQL is the fastest way to understand your own code. Dependency injection went from an annotation I copied to something I reach for deliberately, because constructor-injected components turned out to be the reason I could swap and test pieces in isolation.

The operational half taught me just as much. Docker made the "works on my machine" gap concrete and then closed it. GitHub Actions turned deployment from a nervous manual ritual into a merge. Azure App Service taught me to read platform logs and to respect the resource limits of the tier I'm actually paying for. And the connection pool work gave me something I don't think you can learn from a tutorial: database connections are a small, finite, genuinely contended resource, and code that holds one a moment longer than it needs to is a bug waiting for traffic.

Screenshots

Flight Tracker map view showing 8,029 aircraft as blue icons across the continental United States, with a header bar reading Aircraft 8,029, Last update 3:52:39 PM, and a Live indicator.
Live national view — 8,029 aircraft in the air at once, each icon rotated to its reported heading. The header tracks the current count and the timestamp of the last successful poll.
Close-up of the map near Fargo, North Dakota with an aircraft popup open for N425EV, listing ICAO24 a50eaa, country United States, status Airborne, altitude 17,900 ft, speed 262 kts, heading 327 degrees, and vertical rate 8 meters per second descending.
Clicking any aircraft opens its telemetry — ICAO24 address, Tail Number, origin country, airborne status, altitude, ground speed, heading, and vertical rate.