I run a visa agency in Bali, and the other half of my week goes into Apify Actors. One of them reads JobStreet and JobsDB — the SEA half of SEEK's network, six country sites sharing one public search API, no login, no browser. Boring plumbing, which is why I only looked closely when a trivial question came back wrong.
The question was: how much of the hiring in Kuala Lumpur, Singapore, Jakarta and Manila is remote? The rows carry a workArrangement column with On-site, Hybrid or Remote in it. I pulled 600 of the newest ads from each of the four JobStreet markets on 13 November 2026 — 2,391 job rows — and counted the empties:
| site | rows | workArrangement empty |
|---|---|---|
| my.jobstreet.com | 600 | 200 |
| sg.jobstreet.com | 600 | 300 |
| id.jobstreet.com | 596 | 200 |
| ph.jobstreet.com | 595 | 300 |
Two hundred. Three hundred. Two hundred. Three hundred. Nothing about job adverts comes out in round hundreds. The Actor reads 100 results per request, so those numbers are whole pages, and the suspicion writes itself.
The column is all-or-nothing per response
Grouped by the page each row arrived on, Malaysia looked like this: pages 1 and 2 empty on 100 of 100 rows, pages 3, 4, 5 and 6 filled on 100 of 100. The other three markets had the same shape with the boundaries in different places.
So I stopped going through the Actor and hit the endpoint directly, same URL, over and over. Across 124 search responses that I counted job by job — same endpoint, pages 1 to 6, some of them byte-for-byte the same request repeated — every single one carried the label on 100 of 100 jobs or on 0 of 100. Not one response came back at 61, or 7, or 99. The key workArrangements is present in every job object either way — it is just empty, which is why nothing in the pipeline notices.
That rules out the obvious explanation. If some employers filled the field in and others didn't, responses would be ragged. They are never ragged. The field is not a property of the job. It is a property of whoever answered the request.
It is a coin the server flips, and it does not land where you want
Three runs of the exact same Actor input — unfiltered Malaysia listing, newest first, 600 rows:
| run | rows with a work-arrangement label |
|---|---|
| 1 | 399 of 600 |
| 2 | 0 of 600 |
| 3 | 200 of 600 |
Same input, same minute-ish, same 600-odd ads. Then a longer measurement: 60 requests spread over pages 1–6, 36 came back 0/100 and 24 came back 100/100. And then, an hour later, the same endpoint polled every two seconds for two minutes: 1 of 60 responses carried the column. The rate itself drifts over the day.
The obvious patch — just ask again when the column is missing — does not work either. I took 15 misses and re-requested the identical page up to five times each: 7 recovered, 8 never did. Inside one run consecutive pages tend to agree with each other, which is why whole pages go dark together. You are not sampling an independent coin per request; you are sampling whatever backend state is in force for a stretch of time.
And no, the full advert does not save you. The Actor can fetch each job's complete description through the site's GraphQL endpoint, which is where you would expect the richer fields to live. Ask it for work arrangements and the schema answers:
Cannot query field "workArrangements" on type "Job". The field exists only on search results, and search results hand it over when they feel like it.
What it costs if you don't notice
Over my 2,391 rows, 1,391 carried a label: 95% On-site, 3% Hybrid, 2% Remote.
Compute the share of remote jobs the natural way — remote rows over all rows — and you get 0.9%. Compute it over the rows the site actually labelled and you get 1.6%. The same dataset, the same minute, and a 1.7× error in the number you were going to put in a slide, produced by a column that is silently missing rather than loudly broken. Had my runs landed on a different roll, the same number would have come out differently again.
This is the nastier class of scraping bug. A 403 shouts. A field that is sometimes absent and always null-shaped just quietly moves your averages.
The fix I shipped
There is nothing to fix on the site's side and nothing clever to retry, so the honest fix is to stop lying in the dataset. Every row now carries a second column:
{ "workArrangement": null, "workArrangementReported": false } workArrangementReported is read once per response — did any job in this batch carry the column — and stamped on every row from it. false means the site did not send this column in the response your row came from. true with workArrangement: null would mean the job genuinely has no arrangement. Those were the same value until today, and they are not the same fact.
The rule for anyone counting: filter to workArrangementReported: true before computing any share, and report the denominator. If you are building a time series of remote hiring, run it more often than you need and keep only the labelled responses; the labelled ones are complete, they are just rationed.
A normal row, for scale:
{ "id": "95883725", "title": "Merchandise Planning Manager (Mobile Accessories)", "url": "https://my.jobstreet.com/job/95883725", "site": "JobStreet", "country": "MY", "companyName": "INNOCHIC DISTRIBUTION SDN BHD", "location": "Kajang/Bangi/Serdang", "salary": "RM 6,500 – RM 8,500 per month", "salaryMin": 6500, "salaryMax": 8500, "salaryCurrency": "MYR", "salaryPeriod": "month", "workType": "Full time", "workArrangement": "On-site", "workArrangementReported": true, "classification": "Retail & Consumer Products", "subclassification": "Merchandisers", "listedAt": "2026-11-13T10:29:14Z", "listedAgo": "10h ago" } Three more things the same sample taught me
Pay transparency is a per-country fact, not a site-wide one. In the same 600-row pulls, the share of ads that print a salary at all: Malaysia 51%, Indonesia 37%, Singapore 30%, the Philippines 8%. If your pipeline treats a missing salary as a data quality problem, Manila will look broken. It isn't; that market just doesn't publish numbers.
Half the Philippine salaries are not in pesos. Of the 47 Philippine ads that did print a figure, 10 were in US dollars — $4 – $6 per hour, $5 per hour, $1,600 – $2,000 per month, almost all of them virtual-assistant and offshore-support roles billing a foreign client. Tag those PHP because the site is ph.jobstreet.com and you will put a $5/hour advert and a ₱50,000/month advert in the same column. The Actor reads the currency out of the label text and only falls back to the site's own currency when the label has no sign in it.
The depth limit is not where the docs of any such scraper usually say. The unfiltered Malaysian listing reports 50,174 jobs and genuinely pages to the end: page 502 returns the last 74 rows, page 600 returns nothing. Singapore, however, reports exactly 65,536 — 2^16, three reads in a row — and stops dead at page 656 after 65,536 rows, while the Philippines happily reports 72,570. Singapore's real total is unknown and the number you get back is a ceiling, not a count. Whenever a scraped "total" is a perfect power of two, it is a saturated counter.
Where it doesn't work
Two of the six sites are currently out of reach, and I'd rather say so than let you find out. th.jobsdb.com and hk.jobsdb.com — the same company, the same API path, the same parameters — sit behind Cloudflare and answer 403 to a plain datacenter address, to a residential address, and to a paid unblocker. The four jobstreet.com sites answer the identical request with a 200 from an ordinary server IP. Same engine, different front door, and the Thai and Hong Kong half of the map is a browser problem rather than an HTTP problem. That one is open on my side.
For the four markets that do answer, one call:
curl -X POST "https://api.apify.com/v2/acts/lergassy~jobstreet-jobs-scraper/run-sync-get-dataset-items?token=YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{"keywords":["accountant"],"country":"MY","maxJobsPerSearch":200,"sortBy":"date"}' The Actor is JobStreet Jobs Scraper. It is not the right tool if you need Thailand or Hong Kong today, if you need advertiser phone numbers (the site doesn't publish them), or if you need a reliable remote/on-site signal from a single run — for that, read the flag, keep the labelled responses, and be patient.

