I wrote this article twice. The first version was finished, proofread, and queued to publish. Then someone asked a question about one number in it, and the thesis came apart.
I am publishing the second version, along with the part where I was wrong, because the mistake turned out to be more useful than the article I meant to write.
What I set out to do
Pay-per-event pricing asks you to name a price per unit of value. I had priced eleven Actors that way without knowing what a unit costs me to produce.
So I measured. Twenty-two runs, every Actor I have published, cost taken from the platform’s own accounting rather than from an estimate. Every finished run carries usageTotalUsd, every dataset carries itemCount, and cost per row is the first divided by the second:
const run = await api(`/actor-runs/${runId}`);
const ds = await api(`/datasets/${run.defaultDatasetId}`);
const costPerThousand = (run.usageTotalUsd / ds.itemCount) * 1000;
Enter fullscreen mode Exit fullscreen mode
Two things to know before you copy it. On the free plan, runs and their datasets are kept for a limited window and then removed — I went looking for figures from two weeks earlier and they were simply gone, so save whatever you measure outside the platform. And one run is not a measurement: per-run overhead lands entirely on however many rows that run happened to produce.
The table I almost published
Actor cost / 1,000 rows rows per second bulk job listings $0.008 5.8 Shopify app reviews $0.010 5.7 App Store ranks $0.023 7.7 all jobs at one company $0.035 1.8 company posts $0.059 1.9 jobs + applicant counts $0.105 0.6 company headcount $0.254 0.3 jobs + salary parsing $0.417 0.5 jobs + working filters $0.427 0.5 Google Play ratings $0.508 0.7 job details from a URL (free) $0.730 0.3$0.008 to $0.730. A 96× spread, and the most expensive one was the Actor I give away for free.
The story I told myself
Requests per row, I wrote. That’s the whole story.
The cheapest Actor and one of the most expensive read the same public endpoint, on the same site, with the same library. The cheap one takes ten rows out of every request it makes. The expensive one makes one request per row, because the thing it needs — the salary text, the applicant count — only exists on the individual posting.
It is a good story, and everything in the table lined up behind it. Cheap Actors ran at 5–8 rows per second, expensive ones at 0.3–0.7. Cost and throughput, the same measurement wearing different clothes, because you are billed for the time a machine is held open.
I wrote twelve hundred words on that and finished with the free Actor’s $0.730 as the closing irony.
The question that broke it
Then someone read the draft and asked the least sophisticated question available:
“That $0.730 — is that actually fine as it is?”
I had treated the number as a finding. It was a bug report, and I had filed it as an anecdote.
What I found
I set out to check whether $0.730 was affordable. I ended up checking the run settings of the four most expensive Actors instead.
All four were configured for 4096 MB of memory. The other seven were at 512.
The four most expensive Actors in my table were exactly the four with eight times the RAM. Not approximately — exactly.
Apify bills memory × time. A container with 8× the memory costs 8× per second whether or not anything uses it. And none of these four needs it: no browser, no Playwright, one dependency (apify), plain fetch, and the detail lookups run in a sequential rate-limited loop. Four gigabytes to hold one HTTP response at a time.
I have no memory of choosing 4096. It is the kind of value that lands in a template and then in every Actor you scaffold from it.
The controlled test
Same Actor, same input, three memory settings:
memory cost rows seconds 4096 MB $0.000746 1 3 1024 MB $0.000224 1 3 512 MB $0.000111 1 3Roughly linear in memory, flat in time. And on a real workload rather than a one-row sample — 51 rows, 108 seconds:
Actor at 4096 MB at 512 MB throughput jobs + working filters $0.427 / 1,000 $0.093 / 1,000 0.5 → 0.5 rows/s jobs + salary parsing $0.417 / 1,000 $0.113 / 1,000 0.5 → 0.5 rows/sIdentical throughput. The RAM was never doing anything.
The corrected table
Actor cost / 1,000 was rows/s bulk job listings $0.008 — 5.8 Shopify app reviews $0.010 — 5.7 App Store ranks $0.023 — 7.7 all jobs at one company $0.035 — 1.8 company posts $0.059 — 1.9 jobs + working filters $0.093 $0.427 0.5 jobs + applicant counts $0.105 — 0.6 job details from a URL (free) $0.111 $0.730 0.3 jobs + salary parsing $0.113 $0.417 0.5 Google Play ratings $0.139 $0.508 0.7 company headcount $0.254 — 0.3The spread is 33×, not 96×. The free Actor is no longer the most expensive; it sits in the middle. The Actor now at the top is a genuinely one-request-per-row job, which is the only kind that belongs there.
What survives, and what doesn’t
Requests per row is real. It is still the largest architectural driver in the table, and it accounts for the entire 33× that remains. That part of the first draft was right.
But it was not “the whole story,” and the ranking I built the story on was wrong. Four Actors sat at the top of my table for a reason that had nothing to do with their code — and I explained their position with an argument about their code, an argument I could support for each of them, in a paragraph.
That is the uncomfortable part. The story was not weakly supported. It was well supported and wrong.
A derived number does two jobs: it gives you a value, and it gives you a ranking. If one input is misconfigured, you do not get a slightly wrong value. You get a confidently wrong ranking — and the ranking is the part you reason about.
And check who actually pays
One more thing the second pass turned up.
My free Actor is not monetized, which on Apify means pay-per-usage: “Users can run the Actor without any additional charges beyond the platform usage costs generated by the Actor.” The person who runs it pays, from their own account.
So $0.730 was never my bill. It was the bill I was handing to whoever tried my free sample first — the one Actor in that table whose cost lands on a stranger, and the one I had written up as a personal irony.
It is now about $0.11, and it is still free.
Three things I would check on day one
The run settings, before the code. Memory and timeout are two fields. They are set once, never reviewed, and one of them is a direct multiplier on every bill you generate. Mine was wrong on four Actors and I never looked.
Whether your number ranks or only measures. If you are going to reason about which item is worst, confirm the inputs are comparable before you interpret the order.
Who the cost lands on. Free does not mean free. It means someone else.
Reproduce it
List your Actors, take the recent successful runs, divide usageTotalUsd by the dataset’s itemCount, multiply by a thousand — and print defaultRunOptions.memoryMbytes as a column in the same table. That last column is the one I would add first.
The free Actor is Free LinkedIn Job Scraper — paste a job URL, get JSON back, no login and no paid plan. It is about 4× cheaper to run than it was this morning, and that took no code change at all.
northbell builds honest web scrapers on Apify. Including, evidently, honest corrections.