Wiring an Antidetect Browser Into an Apify Actor Scraping Pipeline
The run log said SUCCEEDED. The dataset had zero items. The gap between those two sentences is why I now run an antidetect browser behind every Apify Actor.
That was my Tuesday. An Apify Actor I'd written months earlier, happily scraping a marketplace on a 4-hour schedule, quietly started returning empty pages. No errors. No 403s. Just a rendered shell with the product grid stripped out — the polite version of getting caught, where the site serves you a page that looks fine and contains nothing.
I spent two days blaming my selectors. Two days. Rewrote the extraction logic twice before it occurred to me to just look at a screenshot.
Apify's own browser stack is good. Crawlee's fingerprint injection is better than raw Playwright by a wide margin. But it's still JavaScript patching a stock Chromium from the outside, and the sites that matter check things JavaScript can't fake — canvas rendering at the driver level, audio DSP timing, font metrics that come from the actual rasterizer. A native antidetect browser patches those in C++, underneath the layer a script can reach at all.
Which is the opinion this whole post rests on, and I know it'll annoy people: fingerprint spoofing in JavaScript has a ceiling, and every stealth plugin on npm is already standing on it.
So I split the job. Apify keeps the parts it's genuinely great at: the request queue, the dataset, the cron, the retry semantics, the run history I can actually click through. JustBrowser supplies the antidetect browser. They talk over CDP.
This tutorial walks through that wiring. It took me an afternoon the first time and about twenty minutes the second.
What We're Building: Antidetect Browser + Apify Actor
An Apify Actor that:
- Pulls its config from Apify's standard input schema
- Leases a JustBrowser profile through the REST API, sets its proxy, and starts it
- Connects to that profile over CDP with Playwright
- Scrapes, pushes rows to an Apify Dataset, then releases the profile
- Runs on an Apify Schedule with retries and run history intact
The important architectural bit: the browser does not live on Apify. It lives on a box you control — a spare Mac mini, a Windows machine you never turn off, whatever — and the Actor reaches it through a private tunnel. Apify Actors are ephemeral. Profiles are not supposed to be.
Prerequisites
- An Apify account. The free tier has a monthly platform-credit allowance that's plenty for testing; as of September 2026 the paid plans start in the low tens of dollars per month. Check their pricing page before you budget anything — it's moved twice since I started using it.
- A persistent host you leave running. JustBrowser ships for Windows x64 and macOS on Apple Silicon, so that's a desktop or a Mac mini rather than a Linux VPS. Mine's an 8GB box and that's been fine so far. Size for the fact that every active profile is a real Chromium process, not a browser tab, and leave headroom.
- JustBrowser — $9.99/mo, or $99.99/year. One plan, and the REST API and CDP launch are in it, trial included.
- A tunnel. Tailscale, Cloudflare Tunnel, or an SSH reverse tunnel. Do not put port 36542 (the JustBrowser API port) on the public internet. (I did this once for "five minutes of testing." The scan traffic showed up in under an hour.)
- Node 20+ and the Apify CLI (
npm i -g apify-cli).
Step 1: Expose the JustBrowser API to Apify
JustBrowser listens locally. Your Actor runs in Apify's cloud. Something has to bridge that gap, and the answer is a tunnel with auth — not a firewall rule.
Cloudflare Tunnel is the quickest:
cloudflared tunnel create justbrowser-api
cloudflared tunnel route dns justbrowser-api jb.yourdomain.com
# config.yml
tunnel: justbrowser-api
credentials-file: ~/.cloudflared/<tunnel-id>.json
ingress:
- hostname: jb.yourdomain.com
service: http://127.0.0.1:36542
- service: http_status:404
Then put a Cloudflare Access policy (service token) in front of it, or terminate auth yourself with a bearer token your API gateway checks. Either way the Actor sends a header; anonymous requests get a 403.
Verify from your laptop before you touch Apify:
curl -H "Authorization: Bearer $JB_API_TOKEN" https://jb.yourdomain.com/api/v1/profiles
Tailscale is the better answer if you're already on it — the Actor joins as an ephemeral node and you skip the public hostname entirely. More setup, less attack surface.
And a hill I'll die on: obscurity isn't auth. A hostname nobody could guess is not a security control, it's a coin flip you keep winning until you don't.
Step 2: Build the Profile Pool
One profile per concurrent run. Create them through the API:
curl -X POST https://jb.yourdomain.com/api/v1/profiles \
-H "Authorization: Bearer $JB_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "apify-marketplace-01",
"fingerprint": {
"os": "Windows 11",
"screen": {"width": 1920, "height": 1080},
"timezone": "Europe/Berlin",
"locale": "de-DE"
}
}'
Note the timezone and locale. They have to match the exit IP you'll route through later — a Berlin proxy behind a America/Chicago profile is a free flag for any detection vendor worth paying for.
Run the cookie warm-up before the first real job. Fresh profiles with an empty history are their own signal; the built-in warm-up walks 50+ popular sites and profile aging fills in realistic history.
I skipped it on my first three profiles. Every fingerprint test was green, I was impatient, and green felt like permission. All three got the empty-grid treatment inside a week — because a flawless fingerprint on a browser with no past is still a browser with no past, and that's the part the tests don't measure.
Step 3: The Actor Skeleton
Create the project:
apify create jb-scraper --template project_empty
cd jb-scraper
npm i apify playwright
Here's the thing most Apify tutorials won't tell you: since the antidetect browser isn't running in your container, you don't need the Playwright base image. Use the slim Node image. My build time went from ~4 minutes to under 40 seconds.
FROM apify/actor-node:20
COPY package*.json ./
RUN npm --quiet set progress=false \
&& npm install --omit=dev --omit=optional
COPY . ./
CMD npm start --silent
.actor/input_schema.json, kept short:
{
"title": "JustBrowser scraper input",
"type": "object",
"schemaVersion": 1,
"properties": {
"startUrls": { "title": "Start URLs", "type": "array", "editor": "requestListSources" },
"profileId": { "title": "JustBrowser profile ID", "type": "string", "editor": "textfield" },
"maxItems": { "title": "Max items", "type": "integer", "default": 200 }
},
"required": ["startUrls", "profileId"]
}
Step 4: Connect Over CDP
This is the whole integration, and it's about fifteen lines.
import { Actor } from 'apify';
import { chromium } from 'playwright';
await Actor.init();
const { startUrls = [], profileId, maxItems = 200 } = await Actor.getInput() ?? {};
const JB_BASE = process.env.JB_BASE_URL;
const JB_TOKEN = process.env.JB_API_TOKEN;
async function jb(path, init = {}) {
const res = await fetch(`${JB_BASE}/api/v1${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${JB_TOKEN}`,
...(init.headers ?? {}),
},
});
if (!res.ok) throw new Error(`JustBrowser ${path} → ${res.status} ${await res.text()}`);
return res.json();
}
const proxyConfig = await Actor.createProxyConfiguration({ groups: ['RESIDENTIAL'] });
const proxyUrl = await proxyConfig.newUrl(profileId); // session-sticky per profile
// The proxy belongs to the profile, not to the start call.
await jb(`/profiles/${profileId}`, {
method: 'PUT',
body: JSON.stringify({ proxy: { url: proxyUrl } }),
});
const { data: { cdp_url: wsEndpoint } } = await jb(`/profiles/${profileId}/start`, {
method: 'POST',
body: JSON.stringify({ headless: false }),
});
const browser = await chromium.connectOverCDP(wsEndpoint);
Two details worth pausing on.
headless: false is deliberate. Leave the host logged into a real desktop session and run headed — headless Chromium still leaks through a handful of signals no amount of flag-setting closes. (We went through the specifics in the Playwright and Puppeteer stealth breakdown.)
And the proxy goes on the profile — via PUT /profiles/{id}, or POST /profiles/batch/proxy if you're updating a pool — not in the Actor's Playwright options. JustBrowser applies it at the engine level with WebRTC protection and DNS-over-HTTPS on the same path. Set it in Playwright instead and you get a browser whose network stack and identity stack disagree with each other.
Guess which one I shipped to production first.
Step 5: Scrape, Push, Release
const context = browser.contexts()[0];
const page = await context.newPage();
const rows = [];
try {
for (const { url } of startUrls) {
if (rows.length >= maxItems) break;
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.waitForSelector('.product-card', { timeout: 15_000 });
const found = await page.$$eval('.product-card', (cards) =>
cards.map((c) => ({
title: c.querySelector('.title')?.textContent?.trim() ?? null,
price: c.querySelector('.price')?.textContent?.trim() ?? null,
})),
);
rows.push(...found);
await Actor.pushData(found);
await page.waitForTimeout(2000 + Math.random() * 3000);
}
} finally {
await browser.close();
await jb(`/profiles/${profileId}/stop`, { method: 'POST' }).catch(() => {});
await Actor.exit();
}
That finally block matters more than it looks. If the Actor dies mid-run and never calls /stop, the profile stays up on your host holding RAM, and the next run gets a 409. Ask me how I know.
Actor.pushData per page rather than once at the end means an aborted run still keeps whatever it already collected. Apify's dataset is append-only — use that.
Step 6: Schedule It
Push and wire the cron:
apify push
In the Apify console: Schedules → Create → cron expression → attach the Actor task. Set the run timeout generously (browser work is slow) but set it — an Actor waiting on a dead tunnel will happily burn compute until you notice. I noticed after eleven hours.
Set Actor memory low, around 512MB–1GB. You're running an HTTP client and a CDP socket, not a browser. That's most of the cost savings in this architecture right there.
For the reverse direction, poll the profile's health score over the REST API on a schedule and kick off an Apify run when it drops — JustBrowser has no webhooks. Still a nice pattern if you're rotating identities on ban signals rather than on a fixed clock.
Common Errors and How to Fix Them
connectOverCDP: connect ECONNREFUSED
The tunnel is down or JustBrowser isn't listening. Check in that order — is cloudflared still alive on the host, then curl http://127.0.0.1:36542/health on the host itself. If health returns ok and the tunnel is up, you've almost certainly got an auth header the Actor isn't sending.
409 Conflict on launch
{"error":{"code":"PROFILE_ALREADY_RUNNING","message":"profile already running"}}
A previous run left the profile up. Call /stop manually, then add the finally block from Step 5 if you skipped it. For pools, lease profiles through Actor.useState() so two runs can't grab the same ID.
Run succeeds, dataset is empty
Your selector is gone or the page served you a bot-check shell. Screenshot before you guess:
await Actor.setValue('debug.png', await page.screenshot(), { contentType: 'image/png' });
The screenshot in the key-value store tells you in two seconds what an hour of log-reading won't.
Launches failing under load
The local API has no documented rate limit, but every start spawns a full Chromium process, so back off exponentially rather than retrying tight — the concurrency and backoff patterns apply here the same as anywhere else.
Next Steps
- Pool the profiles. Ten profiles, leased per run, keyed by target site. Spreads risk and lets identities rest.
- Crawlee, if you want the queue. You can subclass
PlaywrightPluginto connect instead of launch and keep Crawlee's retry and queue machinery. It works. It's also a bit hacky, and I'd only reach for it if you're already deep in Crawlee. - Watch fingerprint drift. Run CreepJS and Pixelscan checks against a profile every few weeks. Chromium is stable, but proxies change and profiles accumulate state.
- Compare architectures. If you're weighing this against GitHub Actions scheduled scraping or routing Scrapy spiders through profiles, the difference is mostly who owns the queue. Apify owns it best.
If the scraped data feeds a dashboard, JustAnalytics covers the metrics side without shipping anything back to the platforms you're measuring. And if you're running paid traffic next to the scraping infrastructure, ClickzProtect is the traffic-quality half of the same problem.
Honest caveat to close on: this setup has a single point of failure, and it's your host. Apify's reliability is no longer the ceiling — yours is. Budget for a health check and a restart policy before you schedule anything you actually depend on.
The empty-dataset Tuesday hasn't happened since.
Frequently Asked Questions
Can I install JustBrowser inside the Apify Actor container itself?
No. Apify Actors are Linux containers and JustBrowser ships Windows x64 and macOS (Apple Silicon) builds only, so there's nothing to install in there. It would be the wrong shape regardless: Actors are ephemeral and get torn down after every run, so a profile living inside one starts cold every time — no cookies, no history, no profile aging. Run JustBrowser on a persistent host you control and connect the Actor to it over CDP. The Actor becomes the orchestration layer; the browser identity stays put.
Does Apify Proxy still work if the browser runs on my own machine?
Yes, but read the path carefully. Traffic goes from your host, out through the Apify Proxy endpoint, to the target — so the exit IP is Apify's, not yours. Set the proxy on the JustBrowser profile (PUT /profiles/) instead of configuring it in the Actor. Whatever exit geo you pick has to match the profile's timezone and locale, or you've built a mismatch that detection vendors read instantly.
Do I need a subscription for this?
Yes — there is only one plan there is — $9.99/mo, or $99.99/year, for unlimited profiles with the REST API and CDP launch included. The 7-day trial is full access, API and all, so you can build this whole pipeline and watch it run before the first charge. Card goes in at checkout; cancel inside the seven days and you aren't billed.
How many Actor runs can share one profile?
One at a time. A profile is a single browser instance with a single cookie jar — two concurrent runs against the same profile will fight over session state and usually throw a 409 from the start endpoint. Create a pool of profiles and lease one per run through a key-value store, or key each profile to a specific target site so runs never collide.
Try JustBrowser
Native Chromium antidetect browser — not extension-based. Real C++ engine patches at the canvas / WebGL / audio / font / screen layer, so 40+ identity parameters are genuine, not faked. REST API for Playwright, Puppeteer, Selenium. $9.99/month or $99.99/year. 7-day free trial, card required — cancel any time in the seven days and you are not charged. Unlimited profiles.
Get started → · How it differs from Multilogin / GoLogin / AdsPower
Related Posts
Ready to manage multiple accounts?
Seven days free, then $9.99/month — one plan, everything included.