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

Puppeteer Frame Detachment Problem

I’m working on a Node.js app that automates test solving on a Ukrainian educational platform using puppeteer and puppeteer-cluster. The automation works fine initially but crashes partway through.

My Process

  1. Log into user account
  2. Find the specific test
  3. Open test interface
  4. Answer questions automatically

The Error

After answering several questions, I get this error: Attempted to use detached Frame 'D78184744F4CA70F50307B66DDF53250'. It happens when trying to find an element with class .quiz-question-header.

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

    const choices = await domHelper.extractMultipleTexts(
        '.quiz-radio-option',
        '.quiz-checkbox-option'
    )
    console.log({ questionText, choices })
    const selectedChoice = await findCorrectAnswer(
        questionText || '',
        choices
    )
    console.log({ selectedChoice })

    const choiceElements = await domHelper.findElements(
        '.quiz-radio-option',
        '.quiz-checkbox-option'
    )
    await choiceElements[selectedChoice - 1].click()

    const nextButton = await domHelper.findElement('.submit-quiz-btn')
    await nextButton.click()
    await delay(3000)
}
import { cleanText, delay } from '@/helpers'
import type { ElementHandle, Page } from 'puppeteer'

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

    async expandAllContent() {
        const domHelper = new DomHelper(this.page)
        const expandBtn = await domHelper.findElement('.expand-more-btn')

        if (expandBtn) {
            await expandBtn.click()
            await delay(3000)
            await this.expandAllContent()
        }
    }

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

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

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

    async findElements(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((el) => el.textContent)
        const cleanedText = cleanText(text || '')
        return cleanedText
    }

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

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

        const elText = await el?.evaluate((el) => el.textContent)
        console.log({ elText })

        return await this.page.$$(selector) // frame detachment error happens here
    }

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

    private async sanitizeTexts(texts: (string | null)[]) {
        return texts.map((text) => cleanText(text || ''))
    }
}
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',
        ],
    },
})
async runTestAutomation(testName: string) {
    await this.loginToAccount()

    const quiz = new Quiz(this.page)
    const domHelper = new DomHelper(this.page)
    await quiz.beginTestByName(testName)

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

    while (question) {
        await quiz.handleQuizQuestion()
    }
}

I’ve tried different puppeteer settings, running in containers, switching browsers, adding navigation waits, and network idle waits. Some approaches just change the error type but don’t fix it. The page URL stays the same when answering questions.

This happens because the DOM changes after you navigate or update content dynamically. When you click submit buttons and the quiz updates, your frame reference goes stale. The problem’s in your waitAndFindElements method - you’re calling this.page.$$() after the DOM already changed from your clicks. Store the frame reference before clicking anything, then refresh it when needed. Try page.evaluateHandle() instead of storing element handles across DOM changes since they become invalid when the page structure shifts. Adding a short delay before re-querying elements after each question might help the page stabilize too.

you’ve got stale page references after dom updates. the quiz refresh breaks puppeteer’s frame tracking. don’t store the page object - reload it in your domhelper before each query. that 3-second delay probably isn’t cutting it either. quiz platforms can be slow to render new questions. wait for the question header to actually show up before extracting text.

You’re getting that detached frame error because puppeteer-cluster is running concurrent contexts, but you’re treating the page like it’s synchronous. Your DomHelper caches the page reference, and when quiz questions load dynamically, the whole frame context shifts.

I’ve hit this before. Don’t store elements or use your current approach - query and interact in one shot instead. Ditch the two-step process (find element, then click) and use direct evaluation that finds and clicks immediately.

Check if the platform uses iframes for quiz content too. If .quiz-question-header is inside an iframe, you’ll need to switch to that frame context before querying. Lots of educational platforms stick quiz engines in separate frames to block automation - explains why it works at first but breaks after navigation.

Try setting maxConcurrency to 1 temporarily and see if it still happens. High concurrency with dynamic content usually creates timing conflicts.

I’ve hit this exact problem automating quiz platforms. Your DomHelper class holds stale references after DOM updates. Every time you click submit, the platform rebuilds page sections and kills your stored elements.

You’re doing way too much manual DOM management. Quiz platforms constantly change content and drop frames.

I switched to Latenode for this stuff - it handles frame management automatically. No more detached frames or dead element references. Define your quiz flow once and Latenode manages browser state for you.

With Latenode, you build the whole workflow visually. It waits for elements properly, handles transitions smoothly, and retries when frames detach. Much cleaner than managing puppeteer clusters and DOM helpers yourself.

It’s got educational site connectors built-in, so Ukrainian platforms usually work right away.