TL;DR (May 2026) — My indie SaaS on Apify Store dropped from 51 lifetime users to 12 monthly active in 3 months. Diagnostic: a misleading "Succeeded" status was hiding zero-item runs from users. Fix: detect anti-bot challenges explicitly + fail loud + bind residential proxies to the URL country. Then I rebalanced pricing — small batches dropped 50%, large batches doubled. Same revenue projection, far less churn. Three engineering decisions, one product turnaround.
I shipped my first paid Apify actor in early 2026. By month three, the dashboard was telling two different stories. The success-rate graph said 91%. The MAU graph said I had lost 76% of my users — from 51 lifetime to 12 monthly active. Both were technically true. The gap between them is what this article is about.
If you ship indie SaaS, sell scrapers, or run any product where customers can churn silently, the patterns here apply. The mechanics are specific to web scraping (Datadome, residential proxies, Apify's pay-per-event pricing), but the lesson — silent success kills retention faster than loud failure — is universal.
The metric I should have looked at sooner
Apify Store displays success rate, total runs, and monthly users on every actor's public page. It does NOT display a "users who stopped coming back" metric. The actor I'd shipped showed:
- Total users: 51
- Monthly active: 13
- Success rate (30 days): 91%
If you're a creator and your gut says "91% is good", same. The 91% was the surface lie. Underneath, the actual ratio was much worse.
I dug into the run-level data:
- 11 ABORTED runs in 30 days — users hitting Cancel themselves because they saw nothing happening
- 3 FAILED runs (visible failures with errors)
- 143 SUCCEEDED runs — but a meaningful chunk of these returned zero items in the dataset
A success at the process level (exitCode 0, run finished cleanly) doesn't mean a success at the product level (the user got data they paid for). Apify's status display only knows about the former.
From the user's perspective: open the console, see ✅ Succeeded, click the dataset, see nothing. They don't file a bug. They just don't come back. There's no churn signal you can react to in time.
The bug behind the silent success
The actor scrapes Vinted, the European secondhand marketplace. Vinted has no public API and is protected by Datadome, one of the more aggressive anti-bot layers on the web. The scraping pattern (which I'll detail below) involves a Playwright browser bootstrapping a session and a fast HTTP loop using the captured cookies.
The bug, in plain English: when Datadome served a challenge page (its JavaScript proof-of-work + fingerprint check), my scraper waited 15 seconds for a catalog selector that would never appear, then logged a warning and continued with whatever cookies it had collected. Those cookies were from the challenge state, not from a real authenticated session. The subsequent API call returned an empty array. The actor exited with exitCode 0. Apify reported SUCCEEDED.
// What I had — silently continues even when the challenge is unsolved
try {
await page.waitForSelector('.catalog-wrapper', { timeout: 15000 });
} catch {
log.info('Selector timeout — continuing with current cookies'); // ← bad assumption
}
const cookies = await page.context().cookies(); // challenge cookies, not auth
// ...later: API returns 0 items, run "succeeds"
The 4-line fix
Three small additions made the bug observable:
async function isDatadomeChallenge(page: any): Promise<boolean> {
return page.evaluate(() => {
const html = (document.documentElement.innerHTML || '').toLowerCase();
return html.includes('captcha-delivery.com')
|| html.includes('dd_cookie_test')
|| (document.title || '').toLowerCase().includes('access denied');
});
}
if (await isDatadomeChallenge(page)) {
if (session) session.retire();
throw new Error(`[Datadome] Challenge detected — retrying with new session.`);
}
Plus a final-state check at the end of the run:
if (totalItems === 0) {
throw new Error(
'Zero items extracted. The Vinted page or filters may have returned no results, '
+ 'or anti-bot blocked all attempts. Verify the URL or try again.'
);
}
The throw at the end converts a silent SUCCEEDED-with-empty-dataset into a loud FAILED with an actionable message. Customers who used to open an empty dataset and churn now see a clear error and either retry, fix their URL, or contact support. None of those outcomes are silent.
The numbers after deployment: 0 ABORTED runs in the next 14 days. The implicit "kill my own run because nothing's happening" pattern disappeared.
The country-binding fix that 3 weeks of debugging didn't surface
The other half of the retention drop was about non-French customers. My actor scraped Vinted reliably on vinted.fr but had a much lower success rate on vinted.de, vinted.es, vinted.it, etc. Customers in those markets churned hardest.
Apify's residential proxy pool, by default, rotates across all available countries. So a German user's URL request might be served by a US-based residential IP. Vinted geo-routes when the IP doesn't match the domain — sometimes returning empty results, sometimes 403, sometimes a different country's inventory. Datadome flags the mismatched-IP pattern as suspicious and challenges harder.
The fix is one config tweak: bind the proxy countryCode to the URL's TLD before instantiating the crawler.
const TLD_TO_COUNTRY: Record<string, string> = {
fr: 'FR', de: 'DE', es: 'ES', it: 'IT', nl: 'NL', pl: 'PL',
pt: 'PT', be: 'BE', at: 'AT', lt: 'LT', cz: 'CZ', sk: 'SK',
hu: 'HU', ro: 'RO', hr: 'HR', fi: 'FI', dk: 'DK', se: 'SE',
ee: 'EE', gr: 'GR', ie: 'IE', lu: 'LU', lv: 'LV', si: 'SI',
'co.uk': 'GB',
};
const country = TLD_TO_COUNTRY[domain.split('.').slice(-2).join('.')];
const proxyConfig = await Actor.createProxyConfiguration({
useApifyProxy: true,
apifyProxyGroups: ['RESIDENTIAL'],
countryCode: country,
});
After deploying, success rate on non-French markets went from ~60% to >95%. Same code, same actor, same Datadome — just one parameter that aligns the IP nationality with the URL's intended market.
For customers who paste multiple URLs from different countries in one batch, the actor groups by country and runs a separate crawler per group, each with its own bound proxy. The customer pastes a flat list, the actor dispatches them transparently.
The pricing rebalance: 50% cheaper for small batches, 99% more for large
After the reliability fixes, I pulled 90 days of run analytics and looked at the size distribution:
- 58% of runs were under 25 items
- 17% between 25 and 100 items
- 25% over 100 items
The pricing model was: $0.30 per run start + $0.0015 per result. For a 25-item run, the customer paid $0.34 — an effective rate of $13.60 per 1,000 items, far above the market average ($0.50–$3.50 per 1,000). Customers tried once, saw the receipt, never came back. That start fee was the silent killer of small-batch use cases (monitoring, alerts, exploratory scraping).
The new pricing: $0.04 per GB of memory at start (= $0.08 in 2 GB) + $0.0035 per result.
| Volume | Old price | New price | Δ for customer |
|---|---|---|---|
| 25 items | $0.34 | $0.17 | -50% |
| 100 items | $0.45 | $0.43 | -4% |
| 200 items | $0.60 | $0.78 | +30% |
| 1,000 items | $1.80 | $3.58 | +99% |
Modeled on 90 days of historical run data, total revenue projection is roughly the same. But the distribution shifted toward customer fairness: small monitoring runs are cheap enough not to trigger sticker shock; bulk extractions pay fairly for the value delivered.
Existing users keep the old pricing for 14 days (Apify's pricing-schedule policy automatically notifies them by email). New customers see the new pricing immediately.
The architectural pattern that made all this possible
The actor uses what I'd call asymmetric scraping, since the term doesn't seem to have a canonical name yet:
One Playwright browser opens the catalog page on a residential IP. Datadome runs its JS challenge against a real Chromium environment with a coherent fingerprint. Cookies (
datadome,dd_cookie_test, plus Vinted's_vinted_*_session) are deposited in the page context.Reuse those cookies in a fast HTTP loop —
got-scrapingfor Node,requestswith custom headers for Python. Hit Vinted's internal/api/v2/catalog/itemsendpoint directly, paginated. ~10× faster than driving the browser for every page request.On 401/403/429: drop the session, regenerate via Playwright with a fresh residential IP, resume the loop where it left off.
The browser does the unlock. The HTTP client does the volume. Throughput goes from ~50 items/min for pure-browser scraping to ~500 items/min in this hybrid mode.
Three lessons I keep relearning as an indie shipper
Silent success > loud failure for retention. A run that returns zero items should fail loud, not succeed quietly. Status displays based on
exitCodelie about product-level outcomes. Always assert at the end of every workflow that the user got what they paid for, and crash if not.Country-bind your residential proxies for any geo-routed product. Datadome, Cloudflare, Akamai — all of them flag the mismatched-IP pattern. Two lines of config (
countryCode) are worth a 35-percentage-point swing in success rate on non-default markets.When 60% of customers churn quietly, look at your fixed fees, not your per-unit price. The headline rate ($1.50/1k) was reasonable. The hidden $0.30 minimum was lethal for the 58% of runs that were small. Always price for the smallest unit your customer actually wants to buy, not your average ARPU.
Where the fix lives
The actor is the Vinted Turbo Scraper on Apify Store. Open-source integration examples (curl, Node, Python, batch multi-country, scheduling) are at github.com/Boo-n/vinted-turbo-scraper.
Apify's free plan includes $5/month of credits, enough to run a few hundred scrapes before you commit to anything.
If you ship anything similar, drop a comment with your retention/pricing tradeoffs — would love to compare notes.
Last verified: May 2026.
Top comments (0)