"Your connection is at risk" is the most valuable sentence an itinerary app can send, and the easiest to get wrong. Send it late and it is useless. Send it on every wobble of an estimate and the user stops reading. And no flight status API can send it for you, because the fact does not belong to a flight. It belongs to two flights and one passenger.
This guide builds it from two subscriptions and about eighty lines of state.
The quantity
For a passenger arriving on flight A and leaving on flight B from the same airport:
layover = B.departure.bestTimeUtc − A.arrival.bestTimeUtcbestTimeUtc is the best time known right now: actual if it has happened, else estimated, else scheduled. Both are gate times, which is what you want. A passenger is not connecting until the door opens, and is not on board after the door closes.
Compare the layover with a threshold you choose. Call the difference the margin. Positive margin, the connection is fine. Negative, it is not. The interesting band is the one in between.
FlightByte reports flights, not journeys
It does not know these two flights are one person's trip, and it does not publish minimum connection times. You bring the pairing and the threshold; it keeps both halves current. Connection, layover and MCT has the vocabulary.
Finding the pairs
A connection exists in your data when two flights on one trip satisfy all of these:
- The arrival airport of the first is the departure airport of the second. Compare
arrival.airport.iatawithdeparture.airport.iata. - The second is scheduled to leave after the first is scheduled to arrive, by less than a day.
- They belong to the same traveller.
Some cities need care. A passenger landing at one London airport and leaving from another has a connection your airport comparison will miss, and no status feed will help with the coach between them. Decide whether you support that, and if you do, treat it as a pairing with a very large threshold.
Store the pair:
create table connections (
id text primary key,
trip_id text not null,
inbound text not null references watched_flights(id),
outbound text not null references watched_flights(id),
threshold_min int not null,
state text not null default 'ok', -- ok | tight | at_risk | broken
announced_min int -- layover at the last alert
);Choosing a threshold
Published minimum connection times vary from twenty-five minutes at a small domestic airport to two hours for an international transfer between terminals at a large one. They are not in the snapshot. A workable default table is short:
| Situation | Threshold |
|---|---|
| Same terminal, both domestic or both within one border-free area | 45 min |
| Same terminal, one leg international | 75 min |
| Different terminals | 90 min |
| Separate tickets, bags to recheck | 120 min |
Read the terminals from arrival.terminal on the inbound and departure.terminal on the outbound. They are often absent until the day; start with the same-terminal assumption and re-evaluate when either appears. Override per airport as you learn which ones are slow. Whatever you choose, show the user the layover in minutes, not just your verdict, so that a traveller who knows the airport can disagree.
Subscribing for it
Both flights are probably already watched for their own sake. Make sure these change types are in each subscription's events:
| Flight | Change types that move the margin |
|---|---|
| Inbound | arrival.estimated.changed, arrival.actual.changed, delay.increased, delay.decreased, arrival.terminal.changed, flight.cancelled, flight.diverted, flight.reinstated |
| Outbound | departure.estimated.changed, departure.scheduled.changed, departure.terminal.changed, departure.gate.changed, flight.cancelled, flight.reinstated, flight.departed |
Keep delayThresholdMinutes low on flights that are part of a connection, five rather than fifteen. A twelve-minute slip that nobody would push to a passenger on its own can be the whole margin of a fifty-minute connection.
The evaluator
Run it whenever a delivery touches either flight of a pair. It reads the two stored snapshots, so it does not matter which flight the event was about or in what order events arrived.
type State = "ok" | "tight" | "at_risk" | "broken";
const TIGHT_BAND = 20; // minutes above the threshold that still deserve a heads-up
const STEP = 10; // re-announce only after this much further movement
export function evaluate(conn: Connection, inbound: Snapshot, outbound: Snapshot) {
if (inbound.status.cancelled || inbound.status.diverted) return verdict(conn, "broken", null, "inbound");
if (outbound.status.cancelled) return verdict(conn, "broken", null, "outbound");
const arrive = Date.parse(inbound.arrival.bestTimeUtc);
const depart = Date.parse(outbound.departure.bestTimeUtc);
const layover = Math.round((depart - arrive) / 60_000);
const margin = layover - thresholdFor(conn, inbound, outbound);
const state: State = margin < 0 ? "at_risk" : margin < TIGHT_BAND ? "tight" : "ok";
return verdict(conn, state, layover);
}
function verdict(conn: Connection, state: State, layover: number | null, cause?: string) {
const worse = rank(state) > rank(conn.state);
const better = rank(state) < rank(conn.state);
const moved = layover !== null && conn.announcedMin !== null && Math.abs(layover - conn.announcedMin) >= STEP;
// Speak when it gets worse, when it recovers, or when a bad situation moves a lot.
const speak = worse || (better && conn.state !== "ok") || (state !== "ok" && moved);
return { state, layover, cause, speak };
}Three rules are doing the work, and they are the same three that keep a delay notifier quiet:
- A band, not a line.
tightexists so the first message is "this is getting close", sent while the user can still do something, rather than "you have missed it". - A step. Inside a bad state, say nothing until the layover has moved another ten minutes. Estimates drift by one or two minutes all afternoon.
- Recovery is news. When an announced risk goes away, say so, once. People rebook on the strength of your first message.
The cases that bite
- The outbound leaves early. It happens, particularly on short sectors with a slot to catch. The formula handles it, because
departure.bestTimeUtcmoves earlier. What breaks is an implementation that only listens to the inbound. Listen to both. - The inbound has landed. Once
arrival.times.actualGateexists, the arrival side is a fact. From then on only the outbound can change the margin, and the alert should switch from "at risk" to directions: gate, terminal, minutes. - The outbound has departed.
flight.departedon the outbound while the inbound is not yetarrivedis the end of the story. Mark itbroken, stop evaluating, and change the screen from a warning to help. - Diverted inbound. The arrival airport is no longer the connection airport.
status.divertedis true anddivertednames where the aircraft went. No arithmetic applies; hand the user to a human or a rebooking flow. - Reinstated. Cancellations are sometimes reversed.
flight.reinstatedshould put the pair back through the evaluator rather than leaving itbrokenfor ever. - Terminal appears late. A terminal published two hours before departure can move the threshold from 45 to 90 and flip a comfortable connection to tight with no change in any time. Re-evaluate on
arrival.terminal.changedanddeparture.terminal.changedas well as on times. - Three legs. Evaluate each adjacent pair independently. A late first leg propagates by itself, because the second leg's risk is computed from real estimates, not from your guess about the first.
What to say
| State change | Message |
|---|---|
| ok to tight | Your connection in Istanbul is down to 58 min. TK1848 now lands 13:58; TK1 leaves 14:56 from the same terminal. |
| tight to at risk | Connection at risk: 32 min in Istanbul. TK1 is still scheduled for 14:56. |
| at risk, moved | Now 18 min. Speak to the crew before landing; they can call ahead. |
| at risk to ok | Good news: TK1 is delayed to 15:40. You have 1 h 16 min. |
| broken, inbound cancelled | TK1848 is cancelled, so your connection to TK1 will not work. Tap for options. |
Times in those sentences come from times.*.local on each flight, which is the clock at that airport. Never build them from bestTimeUtc and a guess at the zone.
Testing it
Unit-test the evaluator with pairs of snapshots; it is a pure function. For the wiring, send signed fixtures to your local receiver with the script in the example receivers repo: an arrival.estimated.changed that eats twenty minutes, a second that eats two more and must be silent, then a departure.estimated.changed on the outbound that gives thirty back and must announce recovery.
What it costs
Nothing beyond the two flights. A connection is not a billable thing; it is arithmetic over two subscriptions you were already paying for, each counted once however many travellers and connections share it.