Getting "Attempted to use detached Frame" error with puppeteer-cluster automation

Puppeteer Frame Detachment Error During Test Automation

I’m working on a Node.js app that automates a Ukrainian testing website using puppeteer and puppeteer-cluster. The automation process works like this:

  1. Log into user dashboard
  2. Find the specific test
  3. Open the test
  4. Complete questions automatically

Everything works fine until I’m partway through answering questions. Then I get this error: Attempted to use detached Frame 'D78184744F4CA70F50307B66DDF53250'

The error happens when trying to find an element with class .quiz-question-header. The element definitely exists on the page.

async handleMultipleChoiceQuestion() {
    const domHelper = new DomHelper(this.page)
    const questionText = await domHelper.extractText('.quiz-question-header') // Error occurs here

    const options = await domHelper.extractMultipleTexts(
        '.quiz-option-radio',
        '.quiz-option-checkbox'
    )
    console.log({ questionText, options })
    const selectedOption = await getAnswerChoice(
        questionText || '',
        options
    )
    console.log({ selectedOption })

    const optionElements = await domHelper.findMultipleElements(
        '.quiz-option-radio',
        '.quiz-option-checkbox'
    )
    await optionElements[selectedOption - 1].click()

    const nextButton = await domHelper.findElement('.submit-quiz-btn')
    await nextButton.click()
    await delay(3000)
}

Here’s my DomHelper class:

import { sanitizeText, delay } from '@/helpers'
import type { ElementHandle, Page } from 'puppeteer'

export class DomHelper {
    constructor(private readonly page: Page) {}

    async loadMoreContent() {
        const domHelper = new DomHelper(this.page)
        const loadButton = await domHelper.findElement('.load-more-btn')

        if (loadButton) {
            await loadButton.click()
            await delay(3000)
            await this.loadMoreContent()
        }
    }

    async findUniqueElements(selector: string, backup?: string) {
        const elements = await this.findMultipleElements(selector, backup)
        const uniqueElements = (await Promise.all(elements.map(this.hasSingleClass))).filter((el) => el !== null)
        return uniqueElements
    }

    async extractUniqueTexts(selector: string, backup?: string) {
        const elements = await this.findUniqueElements(selector, backup)
        const texts = await Promise.all(elements.map((el) => el.evaluate((node) => node.textContent)))
        return this.sanitizeTexts(texts)
    }

    async extractMultipleTexts(selector: string, backup?: string) {
        const elements = await this.findMultipleElements(selector, backup)
        const texts = await Promise.all(elements.map((el) => el.evaluate((node) => node.textContent)))
        return this.sanitizeTexts(texts)
    }

    async findMultipleElements(selector: string, backup?: string) {
        const elements = await this.waitAndFindElements(selector)
        if (elements.length > 0) return elements
        if (backup) return await this.waitAndFindElements(backup)
        return []
    }

    async extractText(selector: string, backup?: string) {
        const element = await this.findElement(selector, backup)
        const text = await element?.evaluate((node) => node.textContent)
        return sanitizeText(text || '')
    }

    async findElement(selector: string, backup?: string) {
        const elements = await this.findMultipleElements(selector, backup)
        return elements[0]
    }

    private async waitAndFindElements(selector: string) {
        const element = await this.page.waitForSelector(selector, {
            visible: true,
            timeout: 4000,
        }).catch(() => null)

        const textContent = await element?.evaluate((node) => node.textContent)
        console.log({ textContent })

        return await this.page.$$(selector) // Error points here
    }

    private async hasSingleClass(element: ElementHandle) {
        const classProperty = await element.getProperty('className')
        const classes = (await classProperty.jsonValue()).toString().split(' ')
        return classes.length > 1 ? null : element
    }

    private async sanitizeTexts(texts: (string | null)[]) {
        return texts.map((text) => sanitizeText(text || ''))
    }
}

Cluster setup:

import { Cluster } from 'puppeteer-cluster'

export const cluster = await Cluster.launch({
    concurrency: Cluster.CONCURRENCY_CONTEXT,
    maxConcurrency: 500,
    puppeteerOptions: {
        headless: true,
        dumpio: true,
        args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
    },
})

Main service method:

async automateTestCompletion(testName: string) {
    await this.loginToAccount()
    const testHandler = new TestHandler(this.page)
    const domHelper = new DomHelper(this.page)
    await testHandler.beginTestByName(testName)

    const currentQuestion = await domHelper.extractText('.quiz-question-header')
    console.log({ currentQuestionInService: currentQuestion })

    while (currentQuestion) {
        await testHandler.handleMultipleChoiceQuestion()
    }
}

I’ve tried different approaches like modifying browser settings, using Docker containers, switching browsers, adding page.waitForNavigation() and page.waitForNetworkIdle() calls. Nothing fixes it completely, though some changes just produce different error messages.

Note that the page URL stays the same when answering questions.

This happens when the page DOM changes while you’re holding onto element references. Your quiz app is probably rerendering between questions, which kills your existing element handles. The issue is you’re creating new DomHelper instances inside methods that already have page access. In handleMultipleChoiceQuestion(), you make a new DomHelper but the original page context dies when the quiz updates its content. I’d stop storing element references across async operations. Skip waitForSelector followed by page.$$ - just query elements directly in a single page evaluation. Try adding a small delay before DOM queries so the page can settle after navigation or updates. What worked for me was a retry mechanism for detached frame errors - catch the exception and re-query the DOM fresh instead of reusing old references.

Had this exact issue scraping dynamic content for performance testing at work. The detached frame error happens because puppeteer loses track of DOM elements when pages update dynamically, even without navigation.

You’re storing element references that go stale. The quiz app’s probably updating the DOM after each question, which detaches your cached elements.

Instead of wrestling with puppeteer’s frame management, I’d switch to a visual automation platform. When I hit similar testing issues, I moved to Latenode - it handles DOM changes automatically without worrying about frame detachment.

Latenode can:

  • Handle dynamic content updates seamlessly
  • Manage the entire quiz workflow visually
  • Scale across multiple concurrent sessions
  • Integrate with your existing Node.js logic through webhooks

You can build the whole flow - login, test selection, question answering - without puppeteer’s frame management headaches. The visual approach is way more reliable for interactive testing.

Check it out: https://latenode.com

Your frame detachment happens because of async DOM operations mixed with page changes. You’re calling page.$$() after the quiz app’s JavaScript modifies the DOM, which kills the frame context.

The issue is in your waitAndFindElements method - you wait for a selector, then separately query for matching elements. Between these steps, the quiz page updates its DOM and makes your frame reference stale.

Ditch the two-step process. Use page.evaluate() to handle all DOM operations at once inside the browser context. This stops frame detachment since everything runs in one execution without jumping between Node.js and browser multiple times.

Also wrap your element interactions in try-catch blocks with retry logic for detached frame errors. When you catch this error, just re-query the elements fresh instead of reusing stale references.