I run a visa agency in Bali; the other half of my week goes into Apify Actors. One of them reads KAYAK flight searches into rows: price, who is selling the ticket — airline or agency — flight numbers, times, stops, layovers, aircraft, and a booking link.
A metasearch engine is worth scraping precisely because of that seller column. Google Flights tells you a route costs $57; KAYAK tells you which of eleven sellers is quoting $57 and links to their checkout. So the seller field had better be right.
Last week I noticed it was not — sometimes. Four runs of the same search disagreed about the price, and not randomly: they disagreed in one direction.
Four runs, one search
DPS → SIN, departing 15 March 2027, one way, economy, sorted by KAYAK's "best". Run on 13 and 14 October, two of them three minutes apart. Here is the same Qantas-coded itinerary in each:
| Run | Row cap | Took | price |
provider |
providersForThisFlight |
|---|---|---|---|---|---|
| 13 Oct 00:18 | 30 | 23 s | 115 | Kiwi.com | 2 |
| 13 Oct 00:21 | 50 | 34 s | 80 | Justfly.com | 28 |
| 14 Oct 00:34 | 30 | 74 s | 115 | Kiwi.com | 19 |
| 14 Oct 00:36 | 50 | 23 s | 99 | Justfly.com | 30 |
Across the 24 itineraries the 13 October pair had in common, the second run was cheaper on 18, identical on 6, and dearer on none. Median gap 7.1%, biggest 43.8%.
A price that only ever moves one way is not noise. It is a partially-read field.
KAYAK's search is a poll, and the payload grows
KAYAK's results page does not fetch a list of flights. It opens a search and then polls it:
POST /i/api/search/dynamic/flights/poll
Each answer carries results[], and each result carries bookingOptions[] — one entry per seller quoting that itinerary, with its own price and deep link. The Actor reads those answers directly instead of scraping the rendered DOM, and picks the cheapest option per itinerary:
const options = (r.bookingOptions || []).filter((o) => o?.displayPrice?.price);
options.sort((a, b) => a.displayPrice.price - b.displayPrice.price);
const chosen = input.allProviders ? options : [options[0]];
That is correct code over an incomplete input. Sellers do not all answer KAYAK at once, so an early poll answer carries one or two bookingOptions per itinerary and a later one carries thirty. price is a minimum over whatever has arrived — and the minimum of a growing set, taken early, is too high every time, by construction.
The run log of the two 13 October runs says it plainly — same search, 180 seconds apart:
00:19:17 DPS-SIN: search API 2 calls, 36 flights in 5 s (page 1 s, search API +2 s)
00:19:17 DPS-SIN 2027-03-15: 29 rows (KAYAK found 29 itineraries)
00:21:55 DPS-SIN: search API 2 calls, 63 flights in 15 s (page 2 s, search API +8 s)
00:21:55 DPS-SIN 2027-03-15: 50 rows (KAYAK found 488 itineraries)
"KAYAK found 29 itineraries" against "KAYAK found 488" — the search's own totalCount is a snapshot too, read five seconds in instead of fifteen.
The stop condition was mine, not KAYAK's
Why did one run stop at five seconds? Because of this line in my wait loop:
if (n >= input.maxResults) break; // n = flights seen so far
Perfectly reasonable as a row-count guard: the buyer asked for 30 rows, 36 itineraries have arrived, stop waiting. Except the poll answer that first clears your row cap is also the thinnest one on prices. The row cap had quietly become the price-completeness setting, and nothing in the input hinted at that.
Controlled test, same minute
Two runs started in the same second from one account, identical input except the cap, both on 4 GB:
{"origin": "DPS", "destination": "SIN", "departureDate": "2027-03-15", "maxResults": 10}
{"origin": "DPS", "destination": "SIN", "departureDate": "2027-03-15", "maxResults": 50}
Ten itineraries appear in both. Six are cheaper in the 50-row run, four are identical, none is dearer:
| Flight | cap 10 | sellers | cap 50 | sellers | gap |
|---|---|---|---|---|---|
| JQ86 | $74 Kiwi.com | 3 | $57 Jetstar | 10 | 29.8% |
| QF5659 | $115 Kiwi.com | 8 | $91 Justfly.com | 32 | 26.4% |
| GA842 | $129 Kiwi.com | 5 | $106 Mytrip | 18 | 21.7% |
| 8B553 | $99 Kiwi.com | 1 | $89 Super.com | 7 | 11.2% |
| SQ937 | $151 Singapore Airlines | 10 | $141 Super.com | 30 | 7.1% |
| KL836 | $83 KLM | 9 | $83 KLM | 9 | 0% |
Median seller count 4 against 8. The $57 Jetstar fare — the actual cheapest way to fly Bali to Singapore that day — is simply absent from the ten-row run. Jetstar had not answered yet.
The bias has a name, and it is Kiwi.com
The seller column is where this gets worse than a wrong price, because the error is not evenly spread. Across all six runs, I split every row by how many sellers had reported:
rows with 1–2 sellers reported (55): Kiwi.com 45 · CheapOair 3 · VietJet 3 · Booking.com 3 · PAL 1
rows with 10+ sellers reported (80): Super.com 28 · Booking.com 24 · Mytrip 18 · Justfly 3 · airlines 4
Kiwi.com is named the cheapest seller in 45 of 55 thin rows and in none of the 80 full ones. Kiwi is not cheap here; Kiwi is fast. Read a search early and you are not measuring who sells the route cheapest, you are measuring whose API answers KAYAK first — and then publishing it as a market finding. That is the kind of error that survives review, because every individual row looks plausible.
What to do about it
providersForThisFlight is the confidence field. It ships on every row and I had been treating it as trivia. One or two sellers on a popular route means you caught the search mid-fill; ten-plus means the field has settled. For price monitoring, store it next to the price and drop the thin rows rather than averaging them in.
Never compare runs taken with different caps. Two of my four runs were a fair comparison; the other pair was comparing a five-second read with a fifteen-second one.
The fix I'm making is to stop treating row count as the exit condition. The poll payload already says status: "complete", and seller counts stop growing before the itinerary list does — waiting on those instead of on maxResults costs about ten seconds per search and removes the whole class of error. Until that build is out, pass a high maxResults even if you only intend to keep the first few rows; rows beyond the cap are not charged, and the extra wait is what buys you the right price.
Three other things this Actor had to learn about KAYAK
The layover belongs to the arriving segment. KAYAK attaches the connection wait to the segment that ends at the connecting airport — EI106 JFK→DUB carries the 75 minutes spent in Dublin — so the obvious read gives you every layover airport one hop early.
currency= is a suggestion. Ask kayak.com for a route in SAR and the prices come back in dollars, correctly labelled as dollars by the payload and not by your request. Rows now convert at a daily rate and keep priceOriginal, currencyOriginal and exchangeRate so nothing is silently rebadged.
Whether you get a payload at all is about the IP, not the route. From today's log, the 50-row run on its first address: requests 3 (api 0, failed 52) — page loaded, search never started. A fresh address returned 64 flights in 22 seconds.
Here is the Actor: Kayak Flights Scraper. Calling it is one request; allProviders gives you every seller of an itinerary as its own row, which is the honest shape for seller research:
import requests
rows = requests.post(
"https://api.apify.com/v2/acts/lergassy~kayak-flights-scraper/run-sync-get-dataset-items",
params={"token": TOKEN},
json={"origin": "DPS", "destination": "SIN", "departureDate": "2027-03-15",
"maxResults": 50, "allProviders": True},
).json()
When not to reach for it. A price calendar or an "is today cheap" verdict is Google's data, not KAYAK's. Seat maps, fare rules and baggage allowances are not on the results page at all. And for one route tracked hourly, a metasearch read costs 30–90 seconds of browser time per search — an airline's own API, where you can get one, is steadier.
The lesson I keep paying for: when a site streams its answer, every field you read has a timestamp, whether or not it carries one. Stopping early is not a performance choice. It is a data choice.
Top comments (0)