The first version of a flight tracker always looks great in the demo. Then it runs for a week and someone sends a screenshot of a 787 that apparently crossed the Atlantic in nine seconds, a Cessna cruising at 51,000 feet, and a track that goes out over the ocean and stops dead.
None of that means the feed is broken. Live surveillance data is messy in specific, repeatable ways, and the difference between a tracker that looks credible and one that looks like a toy is a filtering layer of a few hundred lines. This post is what goes in that layer.
The symptoms, and what actually causes them
Worth mapping these out first, because the fix depends on the cause and several of them look identical on a map.
The aircraft teleports several hundred miles and comes back. Almost always a position decoding failure. ADS-B doesn't transmit absolute coordinates — it transmits a position within a grid cell, and the decoder infers the cell from a pair of messages. Get the cell wrong by one and you land exactly one zone away: 6° of latitude, which is 360 nautical miles. The tell is that these jumps aren't random distances. If you log them and see a cluster around 360 nm, that's what you're looking at, and the CPR section of the message anatomy post explains the mechanism.
A short jump of a few miles, then normal. Usually a stale or duplicated record from a different receiver arriving out of order, or a fusion layer briefly preferring a worse source. Different problem, much smaller magnitude.
The altitude spikes to something absurd for one sample. A corrupted altitude field that still passed CRC, or a Gillham-coded value run through a decoder expecting the 25-foot encoding.
The aircraft sits at 0°, 0°. Null island. Something upstream defaulted a missing position to zero instead of leaving it null. Always a bug, never a real aircraft.
The track ends mid-ocean. Coverage, not an incident. ADS-B is line of sight and the mid-Atlantic has no ground receivers. The aircraft is fine; nobody can hear it.
The aircraft stops moving but keeps updating. A stale record being republished with a fresh timestamp. This is the nastiest one because it defeats naive staleness checks — the record looks current.
The callsign changes mid-flight. Crews retype the flight ID, aircraft get reassigned, and some transponders emit garbage like @@@@@@@@ or all-spaces. Never key a track on callsign.
The same aircraft appears twice. Duplicate ICAO24 addresses do exist — misprogrammed transponders, aircraft transmitting all zeros, and reused hex codes on some military and test airframes.

Layer one: structural validation
Cheap checks that catch a surprising share of the garbage. Reject anything where the values are simply not possible, before any comparison against history.
MAX_GS_KT = 700 # civil subsonic ceiling, with headroom
ALT_RANGE_FT = (-1500, 60000) # Dead Sea airports to business-jet ceilings
def structurally_valid(p):
lat, lon, alt, gs = p.get("lat"), p.get("lon"), p.get("alt"), p.get("gs")
if lat is None or lon is None:
return False, "no position"
if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
return False, "coords out of range"
if lat == 0 and lon == 0:
return False, "null island"
if alt is not None and not (ALT_RANGE_FT[0] <= alt <= ALT_RANGE_FT[1]):
return False, "altitude out of range"
if gs is not None and not (0 <= gs <= MAX_GS_KT):
return False, "ground speed out of range"
return True, NoneTwo notes on the thresholds. The altitude floor is negative on purpose — Bar Yehuda in the Jordan Valley sits about 1,240 feet below sea level, and an aircraft on the ground there reports a negative altitude legitimately. The ground speed ceiling of 700 knots excludes supersonic traffic, which is fine for almost everyone but wrong if you're tracking Concorde's successors or military types; raise it and lose some filtering power.
Layer two: the kinematic gate
This is the one that does the heavy lifting. An aircraft has a maximum speed, so any two consecutive fixes imply a speed, and if that implied speed is impossible then one of the two fixes is wrong.
from math import radians, sin, cos, asin, sqrt
def haversine_nm(lat1, lon1, lat2, lon2):
lat1, lon1, lat2, lon2 = map(radians, (lat1, lon1, lat2, lon2))
h = sin((lat2 - lat1) / 2) ** 2 + cos(lat1) * cos(lat2) * sin((lon2 - lon1) / 2) ** 2
return 2 * 3440.065 * asin(sqrt(h))The important design decision is what to do with the anchor point when you reject a fix. If a bad position arrives and you reject it but also make it the new reference, the next good position will look like a jump too, and you'll reject the entire rest of the track. Reject the point and keep the last known-good fix as the anchor. A single outlier should cost you one sample, not the flight.
The other trap is dt. Duplicate timestamps produce a division by zero, out-of-order arrivals produce negative time and nonsense speeds, and both are common when records come from multiple receivers.
Putting it together
from math import radians, sin, cos, asin, sqrt
MAX_IMPLIED_KT = 900 # gate on speed implied between two fixes
MAX_VS_FPM = 12000 # implied vertical rate ceiling
GAP_SECONDS = 300 # beyond this, don't connect the dots
SESSION_SECONDS = 1800 # beyond this, it's a different flight
class TrackCleaner:
"""Per-aircraft filter. Feed it points in time order; it returns
(accepted, reason) and keeps the last accepted point as the anchor."""
def __init__(self):
self.last = None
def push(self, p):
ok, why = structurally_valid(p)
if not ok:
return False, why
prev = self.last
if prev is None:
self.last = p
return True, "first fix"
dt = (p["t"] - prev["t"]).total_seconds()
if dt <= 0:
return False, "out of order or duplicate timestamp"
if dt > SESSION_SECONDS:
self.last = p # new session: anchor, don't gate against old fix
return True, "new session"
dist = haversine_nm(prev["lat"], prev["lon"], p["lat"], p["lon"])
implied_kt = dist / (dt / 3600.0)
if implied_kt > MAX_IMPLIED_KT:
# Don't update the anchor — a single bad fix shouldn't poison the track.
return False, f"jump: {dist:.0f} nm in {dt:.0f}s = {implied_kt:.0f} kt"
if p.get("alt") is not None and prev.get("alt") is not None:
implied_fpm = abs(p["alt"] - prev["alt"]) / (dt / 60.0)
if implied_fpm > MAX_VS_FPM:
return False, f"altitude spike: {implied_fpm:.0f} fpm"
self.last = p
return True, "gap — do not interpolate" if dt > GAP_SECONDS else "ok"Run that against a stream seeded with each of the failure modes above and you get:
12:00:00 KEEP first fix
12:00:10 KEEP ok
12:00:20 KEEP ok
12:00:30 DROP jump: 360 nm in 10s = 129774 kt
12:00:40 KEEP ok
12:00:50 DROP null island
12:01:00 DROP altitude out of range
12:01:10 KEEP ok
12:01:10 DROP out of order or duplicate timestamp
12:01:20 DROP altitude spike: 90000 fpm
12:11:40 KEEP gap — do not interpolate
13:06:40 KEEP new sessionThe 360 nm jump in the fourth line is the CPR zone error, showing up at exactly the distance the encoding predicts. The track resumes cleanly at 12:00:40 because the anchor was never corrupted.
Gaps are not the same as jumps
A jump is bad data you should discard. A gap is an absence of data, and discarding is not an option — you have to decide what the absence means.
The distinction that matters: did this aircraft land, or did it fly out of coverage? The state at the last good fix answers it. An aircraft last seen at FL370 doing 480 knots did not land; it went somewhere your receivers can't hear. An aircraft last seen at 400 feet and 130 knots on final, three miles from a runway, landed.
That gives you a workable rule. If the last fix was at cruise, hold the track open and mark it as out of coverage rather than terminated. If it was low, slow, and near an airport, close the track as a landing. If it was low and slow nowhere near an airport, flag it — that's either a real event or a data problem, and either way a human should look.
The thing not to do is interpolate a straight line across a long gap and store the result as though it were observed. Draw the dashed line on the map if you like, but keep the synthetic points out of your database, or someone will eventually run analytics over them and get answers about flights that were never observed. Our feed exposes last_seen per aircraft precisely so you can distinguish "we saw this 2 seconds ago" from "we saw this 4 minutes ago", and the whole question of why sources disagree turns on whether a given system is handing you observations or projections.
Sessionising: turning a position stream into flights
Live feeds give you aircraft, not flights. To get flights you have to cut the stream, and the cut points are:
- A time gap longer than your session threshold. Thirty minutes is a reasonable default; shorter risks splitting a flight that crossed an oceanic gap, longer risks merging a quick turnaround.
- A ground-to-air transition. The aircraft was on the surface and now it isn't.
- A landing at a different airport than the last departure.
Key the session on icao24 plus a start timestamp, never on callsign. Our ADS-B records carry first_seen alongside last_seen for exactly this — the aircraft's current tracking session — which saves you doing the segmentation yourself for the live case, though you'll still want your own logic if you're building historical tracks.

Smoothing, carefully
Once the outliers are gone, positions still jitter, particularly for MLAT-derived tracks where errors of a few hundred metres are ordinary. The temptation is to run everything through a Kalman filter. Resist it slightly.
For altitude, a 3- or 5-point median filter removes single-sample spikes without touching real climbs and descents, and it's hard to get wrong. Use median rather than mean — a mean drags the whole window toward an outlier, which is the opposite of what you want.
For position, a light alpha-beta filter or a simple 1D Kalman on each coordinate works, but every smoother trades noise for lag. A filter tuned aggressively enough to make cruise look glassy will visibly cut the corner on a turn and lag a go-around by several seconds. If you're rendering a map, smooth lightly and let the map's own animation do the rest. If you're computing anything a decision depends on — separation, arrival estimates, geofence crossings — use the raw filtered positions and accept the jitter, because a smoothed position is partly a guess and you don't want guesses in that path.
Never smooth across a gap boundary. The filter will happily produce a confident curve through fifteen minutes of nothing.
Identity problems
Two failure modes that filtering doesn't catch, because the data is internally consistent and still wrong.
Duplicate ICAO24. If two aircraft transmit the same address, you get one track that jumps between two real aircraft. The kinematic gate will reject roughly half the samples and the track will look intermittent rather than obviously broken. If you see a specific hex address with a persistently high rejection rate, that's the signature. The fix is a blocklist; there's no clever algorithmic answer.
Callsign flapping. Treat the callsign as an attribute of the observation, not the identity of the track. Keep the most recent non-empty value, discard values that are all spaces or all @, and don't be surprised when a positioning flight changes its ID in the air.
What to filter server-side vs client-side
Structural validation and CRC checks belong as far upstream as possible — there's no reason to ship a position at 0°, 0° to anybody. We drop the obviously impossible before it reaches the API.
What we deliberately don't do is interpolate, forward-project, or aggressively smooth. You get observed positions with the time they were observed. That's the less flattering choice in a side-by-side demo against a feed that's been pre-smoothed into looking perfect, and it's the right one for anything you're going to compute on, because you can always add smoothing downstream and you can never remove it.
The thresholds in this post are starting points, not settings. If you're tracking gliders, MAX_IMPLIED_KT at 900 is useless; if you're tracking drones, the altitude range is wrong by an order of magnitude. Log every rejection with its reason for the first few weeks and tune from what you actually see, because the rejection log is also your early warning system — a sudden change in the rate or the mix of reasons usually means something upstream changed.
Getting the data
The ADS-B endpoint returns live positions with last_seen and first_seen timestamps, is_on_ground, and registry enrichment already joined, which removes a couple of the identity problems above before you start. There's also GET /adsb/health if you want to distinguish "no aircraft in this box" from "the feed is having a bad minute" — worth wiring into your monitoring, since a clean-looking empty response is otherwise indistinguishable from a coverage collapse.
The free trial is enough to pull a few days of data and run your cleaner against real traffic, which is the only way to find out which of these thresholds are wrong for your use case. If you're weighing this against running your own receivers and decoding stack, build vs buy for ADS-B ingestion works through what that actually costs.
