You'd think detecting a landing is just watching a flag flip. It isn't. Every ADS-B position report carries an "on-ground" flag, and the naive approach is to wait for it to go from false to true and call that the landing. In production that flag lies too often to trust: transponders don't reliably set it at high-elevation airports, it flickers near touchdown, and it vanishes entirely during signal dropouts. This is a deep-dive into how our flight-detection service actually decides when an aircraft took off and landed — and why takeoff is a one-line edge-trigger, but landing needs a geofence, an energy check, and a debounce.

The building blocks

Raw transponder data arrives as a decoded position/velocity stream — one record per aircraft, roughly every 5 seconds. Each tick from the live ADS-B endpoint carries what we need: latitude/longitude, barometric altitude, ground_speed, vertical_rate, track heading, an is_on_ground flag, and the callsign. A background process — "the detector" — keeps an in-memory state machine per aircraft, keyed by its ICAO 24-bit address, and re-evaluates it on every tick.

One design decision matters up front: airports and routes come from a reference dataset, not from the position stream. The fact that a callsign is flight XX123 from KJFK to EGLL comes from the callsign → route mapping; the live telemetry is used only to decide when the aircraft took off and landed, and whether it deviated from that expected route. Routes from a reference dataset, timing from live telemetry — keeping those concerns separate is what makes the rest tractable.

The flight moves through a state machine: ON_GROUND → TAXI_OUT → AIRBORNE → LANDED → POST_FLIGHT → ARCHIVED, with a SIGNAL_LOST branch for dropouts.

Takeoff detection (and why it gets to be simple)

Takeoff is the easy half. The instant the ground flag flips from true to false, that timestamp is recorded as the takeoff time. No debounce, no geofence. A spurious "airborne" flicker on the departure roll is rare, and if one slips through it's cheap to correct later. We do distinguish taxiing from a parked aircraft: once ground speed exceeds 35 knots while still flagged on-ground, the flight enters TAXI_OUT.

Takeoff can afford this simplicity because a departing aircraft is accelerating away from a known state — it was parked, now it's moving and climbing, and the signal is unambiguous. Landing is the opposite: an aircraft can be low and slow for all sorts of reasons that aren't a landing, which is why it gets the complicated treatment.

A Lufthansa Airbus A320 climbing away from the runway just after takeoff, its landing gear retracting

Landing detection: the core algorithm

Landing is deliberately not the mirror image of takeoff. Instead of trusting the ground flag, the detector combines three independent signals:

  1. Geofence — is the aircraft within a 4 nautical-mile radius of its expected arrival airport (from the route reference dataset)?
  2. Energy state — is it below 3,000 ft AGL and under 200 knots ground speed? A plane can be geographically over an airport while cruising across it at altitude; the energy check rules that out.
  3. Debounce — both conditions must hold for two consecutive samples (~10 seconds) before landing is confirmed, which filters out a single noisy reading.
within_circle = distance_to_arrival_airport <= 4.0 nm
low_energy    = altitude_agl < 3000 ft AND ground_speed < 200 kts

if within_circle AND low_energy:
    consecutive_hits += 1
else:
    if altitude_agl > 4000 ft OR ground_speed > 250 kts:
        consecutive_hits = 0   # go-around / overflight: reset

landing_confirmed = consecutive_hits >= 2

That reset branch is what makes the rule go-around-aware. If an aircraft comes in low and slow but then climbs back above 4,000 ft or accelerates past 250 knots, the counter is wiped — correctly handling touch-and-goes and aborted approaches instead of falsely logging a landing. After landing is confirmed, a second debounce (3 consecutive slow samples, ~15 seconds) moves the flight to a stable POST_FLIGHT state before archiving, absorbing the brief speed noise of rollout and turning off the runway.

We tried machine learning first

An earlier version scored landing likelihood with a logistic model. It blended weighted features — distance to airport, percentage of route completed, descent rate, altitude AGL, speed, and heading convergence with the runway — through a sigmoid into a single probability:

probability = sigmoid((weighted_feature_sum - 0.40) * 10)
landing_likely = probability > 0.5

It worked. It also wasn't worth it. The probabilistic model was harder to tune, much harder to explain when it misfired ("why did it think a plane holding at 5,000 ft had landed?"), and it didn't meaningfully outperform the deterministic geofence-plus-hysteresis rule that replaced it. We retired it. The lesson is an old one worth relearning periodically: on noisy real-world sensor data, an interpretable rule you can reason about beats a sophisticated model you can't — especially when the failure modes land in a production on-call rotation.

The long tail of edge cases

There are no unit tests for this logic in the usual sense. The real test suite is a long commit history of production bugfixes — ghost flights, frozen positions, spoofed GPS, misattributed routes. This is an algorithm shaped empirically by live feed noise, not derived from first principles, and the edge cases are where it earns its keep.

The ground flag never sets (high-elevation airports). Some transponders never flag surface position at high-altitude fields. So the detector computes altitude-above-ground independently using the airport's known elevation, and if AGL < 200 ft, speed < 80 kts, and vertical rate is stable, it forces the ground flag on. Critically, this override only ever goes false→true, never the reverse — a deliberate asymmetry so a fast taxi or takeoff roll is never mistaken for "airborne."

The receiver comes online mid-flight. If an aircraft is first seen already airborne, there's no takeoff edge to catch. The detector back-calculates an estimated takeoff time by dividing current altitude-above-ground by a typical climb rate (1,500 ft/min default, or the aircraft's observed climb rate if available), capped at 15 minutes back.

The aircraft vanishes before crossing the geofence. Coverage gaps near the destination are common. If the last known position was within 10 nm of the expected arrival airport, descending, and the feed has then been silent for 5+ minutes, the landing time is inferred from that last good position rather than left blank.

Touch-and-goes and bounces. A brief airborne flicker on the ground is discarded if the airborne segment lasted under 2 minutes or produced fewer than 20 position samples — too short and too sparse to be a real flight.

Bad callsign/route matches and GPS spoofing. If a matched route's great-circle distance is more than 20× the distance actually flown, the pairing is rejected as a mismatched callsign. Separate integrity checks flag position jumps over 100 nm in under 30 seconds, ground speeds above 600 kts, or a flight path whose midpoint sits more than 500 nm off the matched route — classic spoofing signatures. Flights under spoofing suspicion are excluded from certain automated cleanup (like "frozen position" archival) so a spoofed jump isn't misread as the aircraft going quiet.

Syntax-highlighted source code on a monitor, the kind of detection logic described in this article

The tunables

Almost every threshold above is a named constant, tuned against real feed noise rather than derived:

ParameterValuePurpose
Taxi speed threshold35 ktsDistinguish taxiing from parked
Landing geofence radius4 nmLanding proximity check
Landing debounce2 ticks (~10 s)Filter noisy single readings
Post-landing stability3 ticks (~15 s)Confirm rollout before archiving
Minimum airborne duration120 sReject false "bounce" flickers
Minimum airborne samples20Same, by sample count
Default climb rate1,500 ft/minBack-estimate takeoff for late-seen aircraft
Signal-loss radius10 nmInfer landing after silent disappearance
Signal-loss silence window300 sSame
Route-distance mismatch ratio20×Reject bad callsign/route matches

The philosophy: layered fallbacks over false precision

When no landing is ever confirmed and no signal-loss inference applies, a three-tier fallback runs at archive time: use the last observed position if it's within 50 nm and below 10,000 ft; else project forward from distance ÷ speed if below 35,000 ft and flag the duration as estimated with a discounted confidence; else leave the landing time unset rather than guess wildly. Every completed flight record carries a duration_source (exact / estimated / approximate), a route_source, a numeric confidence score, and flags for suspected spoofing or diversion.

That's the whole philosophy. This system doesn't try to be perfect on the first pass — it makes a confident call when the signals agree, degrades gracefully through layers of fallback when they don't, and is honest about its own uncertainty via confidence scoring. That's the right shape for anything built on noisy real-world sensor data: not a single clever model, but a deterministic core wrapped in humility.

If you're building on live aircraft data, the raw material is the ADS-B tracking endpoint and the callsign → route mapping; to turn hex codes and callsigns into labeled flights, chain in aircraft lookup. SkyLink API gives you a free tier of 1,000 requests/month to build against, with paid plans starting at $19/mo for production traffic. It's available through the free trial — sign up, grab a key, and start reasoning about takeoffs and landings from real telemetry.