apify/crawlee · error · Error

Remote browser endpoint() must return a URL string or an obj

Error message

Remote browser endpoint() must return a URL string or an object with a non-empty 'url'.

What it means

If the endpoint function returns a non-string (an object), RemoteBrowserPool requires it to contain a non-empty url property (optionally with a context). An object without url — or with url: '' — is invalid and throws this message. This validates the { url, context? } shape the pool expects.

Source

Thrown at packages/browser-pool/src/remote-browser-pool.ts:81

    constructor(
        endpoint: RemoteBrowserEndpoint,
        onRelease: ((info: { endpoint: string; context?: Record<string, unknown> }) => unknown) | undefined,
        log: CrawleeLogger,
    ) {
        this.#endpoint = endpoint;
        this.#onRelease = onRelease;
        this.#log = log;
    }

    async resolve(options?: { proxyUrl?: string }): Promise<{ url: string; token: number }> {
        const resolved = typeof this.#endpoint === 'function' ? await this.#endpoint(options) : this.#endpoint;

        let result: ResolvedRemoteEndpoint;
        if (typeof resolved === 'string') {
            if (!resolved) throw new Error('Remote browser endpoint resolved to an empty string.');
            result = { url: resolved };
        } else if (!resolved?.url) {
            throw new Error("Remote browser endpoint() must return a URL string or an object with a non-empty 'url'.");
        } else {
            result = resolved;
        }

        const token = this.#nextToken++;
        this.#sessions.set(token, { url: result.url, context: result.context, released: false });
        return { url: result.url, token };
    }

    async release(token: number): Promise<void> {
        const session = this.#sessions.get(token);
        // Release at most once per session — guards a close()/teardown race (the `released` flag is set
        // synchronously before the awaited onRelease, so releaseAll() can't double-fire an in-flight release).
        if (!session || session.released) return;
        session.released = true;

        try {
            await this.#onRelease?.({ endpoint: session.url, context: session.context });

View on GitHub (pinned to dbe57fb09c)

Solutions

  1. Return the object as { url: '<ws-or-http-url>', context?: ... } with a non-empty url string.
  2. Fix field-name mismatches in the mapping from your provisioning API to the { url, context } shape.
  3. Coerce/fall back: if the resolved value has no url, either throw a clearer error or fall back to a string endpoint.

Example fix

// before
endpoint: async () => ({ endpoint: await getConnection() })
// after
endpoint: async () => ({ url: await getConnection() }) // must use the 'url' key
Defensive patterns

Strategy: type-guard

Validate before calling

const resolved = await endpointFn();
if (typeof resolved === 'object' && resolved !== null && !('url' in resolved && resolved.url)) {
  throw new Error(`Endpoint object missing url: ${JSON.stringify(Object.keys(resolved))}`);
}

Type guard

function isResolvedRemoteEndpoint(v: unknown): v is { url: string; context?: unknown } {
  return typeof v === 'object' && v !== null && typeof (v as { url?: unknown }).url === 'string' && (v as { url: string }).url.length > 0;
}

Try / catch

try {
  await remotePool.init();
} catch (err) {
  if (err instanceof Error && err.message.includes("non-empty 'url'")) {
    // normalize endpoint result to { url } shape and re-init
  } throw err;
}

Prevention

When it happens

Trigger: Endpoint function returning an object like {} or { context: ... } without a url, or { url: '' }, to RemoteBrowserPool's endpoint option.

Common situations: Typo in the returned field name (e.g. endpoint: or wsUrl: instead of url:); API responses mapped incorrectly so url lands in another key; null/undefined url after optional chaining from a provisioning service.

Related errors


AI-assisted analysis of apify/crawlee@dbe57fb09c (2026-08-30). Data as JSON: /api/errors/f94e8a6a6c1e30c3. Report an issue: GitHub.