apify/crawlee · error · Error

Page with ID: ${id} already exists.

Error message

Page with ID: ${id} already exists.

What it means

newPage() generates or accepts a page id and tracks in-flight page creation promises in this.pages to prevent duplicate concurrent creation under the same id. This plain Error is thrown when the given (or default nanoid) id already has an entry, because two pages with the same id would corrupt the pool's page bookkeeping.

Source

Thrown at packages/browser-pool/src/browser-pool.ts:469

     * for `proxyUrl` and `ignoreTlsErrors` respectively. Explicit `proxyUrl` /
     * `ignoreTlsErrors` values in the options take precedence.
     *
     * Beyond fingerprint caching and proxy configuration, no other session
     * properties are consumed — cookie and header injection remain the
     * crawler's responsibility.
     */
    async newPage(options: BrowserPoolNewPageOptions<PageOptions, BrowserPlugins[number]> = {}): Promise<PageReturn> {
        const {
            id = nanoid(),
            pageOptions,
            browserPlugin = this.pickBrowserPlugin(),
            session,
            proxyUrl = session?.proxyInfo?.url,
            ignoreTlsErrors = session?.proxyInfo?.ignoreTlsErrors,
        } = options;

        if (this.pages.has(id)) {
            throw new Error(`Page with ID: ${id} already exists.`);
        }

        if (browserPlugin && !this.browserPlugins.includes(browserPlugin)) {
            throw new Error('Provided browserPlugin is not one of the plugins used by BrowserPool.');
        }

        // Bind the limiter callback to the current async-hooks context. p-limit
        // otherwise resumes queued callbacks in the previous task's
        // AsyncLocalStorage context, leaking aborted cancelTasks across unrelated
        // requests (https://github.com/apify/crawlee/issues/3670). Mirrors the
        // fix p-limit landed upstream in v5 (sindresorhus/p-limit#71); v5 is an
        // ESM-only rewrite, so we can't bump it in Crawlee v3.
        // Besides the cancelTask leak, the wrapper also keeps the per-request *storage transaction*
        // ALS-scoped: without it, a queued callback would resume in the previous request's async
        // context and run request B's storage writes inside request A's transaction.
        // TODO(crawlee@v4): bump p-limit to v5 and drop this AsyncResource.bind wrapper.
        // Limiter is necessary - https://github.com/apify/crawlee/issues/1126
        return this.#limiter(

View on GitHub (pinned to dbe57fb09c)

Solutions

  1. Use unique ids per newPage call (omit `id` to let nanoid generate one).
  2. Await the previous page promise before creating another page with the same id.
  3. Catch the error and generate a fresh id, or check `pool.pages.has(id)` before calling.

Example fix

// before
await pool.newPage({ id: requestId });
await pool.newPage({ id: requestId }); // throws
// after
const id = pool.pages.has(requestId) ? `${requestId}-${nanoid()}` : requestId;
await pool.newPage({ id });
Defensive patterns

Strategy: validation

Validate before calling

if (pool.pages.has(id)) id = `${id}-${nanoid()}`;
await pool.newPage({ id });

Try / catch

try {
    await pool.newPage({ id });
} catch (err) {
    if (err.message.startsWith('Page with ID:')) {
        await pool.newPage({ id: `${id}-${nanoid()}` });
    } else throw err;
}

Prevention

When it happens

Trigger: Calling pool.newPage({ id: 'same-id' }) (or page()/pagePromises() which delegate to newPage) twice with the same id while the first page is still being created or tracked; reusing an id after an earlier newPage that hasn't completed/cleaned up.

Common situations: Retry logic that reuses a request/page id without waiting for the first attempt; request-key collisions when mapping crawler requests to page ids; creating pages in a loop with a constant id.

Related errors


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