Nadim Tuhin
Published on

Building Robust Browser Automation: Essential Patterns and Best Practices

Authors

My first Puppeteer script worked perfectly—until it didn't. Facebook changed a class name, and 100+ accounts stopped posting. I spent a weekend rewriting everything with fallback selectors, proper waits, and cleanup handlers.

That weekend taught me the difference between "works on my machine" and "works in production." This guide is the playbook I wish I had.

TL;DR: The 5 Rules

RuleAnti-PatternBetter Pattern
Selectors.x1heo9i (obfuscated)[aria-label="Post"] or text-based
WaitswaitForTimeout(5000)waitForSelector({ visible: true })
CleanupNo finally blockAlways finally { browser.close() }
SessionsShared page stateFresh page per task + distributed locks
DebuggingSilent failuresScreenshots + HTML on every error

Read on for code examples and production patterns from automating Facebook Marketplace (100+ accounts), vehicle uploads, and message pipelines.


Selector Strategies

CSS Selectors vs Text-Based Discovery

CSS Selectors: Fast, reliable when structure is stable

// ✅ GOOD - Stable semantic selector
const title = await page.$eval('[role="heading"]', (el) => el.textContent)

// ✅ GOOD - Test ID selector
const button = await page.$eval('#submit-button', (el) => el.click())

// ❌ BAD - Obfuscated class (Facebook does this)
const title = await page.$eval('.x1heo9i .x1gj2m3', (el) => el.textContent)

Text-Based Discovery: Best for dynamic UIs (Facebook, Instagram)

// ✅ GOOD - Find by visible text
const buttons = await page.$$eval('button', (buttons) =>
  buttons.filter((btn) => btn.textContent.includes('Post'))
)

// ❌ BAD - Brittle class name
const button = await page.$('.x78zum5') // Changes weekly

Pattern: Multiple Fallback Selectors

Never rely on a single selector. Always provide alternatives:

// Strategy: Try selectors in order of reliability
const titleSelectors = [
  '[role="heading"]', // Most reliable (ARIA)
  'span[dir="auto"]', // Common Facebook pattern
  '.marketplace-listing-title', // Class-based (brittle)
  'h3', // Last resort
]

let title = null
let usedSelector = null

for (const selector of titleSelectors) {
  const element = await page.$(selector)
  if (element) {
    title = await page.evaluate((el) => el.textContent, element)
    usedSelector = selector
    break
  }
}

if (!title) {
  // Graceful degradation - don't crash
  console.warn('Title not found, tried:', titleSelectors.join(', '))
  title = 'Unknown'
}

// Log for debugging selector effectiveness
logSelectorUsage('title', usedSelector)

Anti-Pattern: Hardcoded Classes

// ❌ NEVER DO THIS - Facebook classes change daily
const postButton = await page.$('.x1heo9i.x1gj2m3')

// ✅ INSTEAD - Use ARIA attributes
const postButton = await page.$('[aria-label="Post"]')

// ✅ OR - Use text discovery
const postButton = await page.$$eval('button', (buttons) =>
  buttons.find((btn) => btn.textContent.includes('Post'))
)

Pattern: Wait for Element Before Interaction

// ❌ WRONG - No wait, race condition
await page.click('#submit')

// ✅ CORRECT - Wait for visibility
await page.waitForSelector('#submit', { visible: true, timeout: 10000 })
await page.click('#submit')

// ✅ BETTER - Wait and verify
await page.waitForSelector('#submit', {
  visible: true,
  timeout: 10000,
})
const button = await page.$('#submit')
if (!button) throw new Error('Submit button not visible')
await button.click()

Wait Strategies

Explicit vs Implicit Waits

Explicit Waits: Define what you're waiting for

// ✅ GOOD - Explicit wait for specific condition
await page.waitForSelector('[aria-label="Loading"]', {
  hidden: true,
  timeout: 30000,
})

// ✅ GOOD - Explicit wait for navigation
await Promise.all([
  page.waitForNavigation({ waitUntil: 'networkidle2' }),
  page.click('a[href="/next"]'),
])

// ✅ GOOD - Wait for custom condition
await page.waitForFunction(() => document.querySelector('[data-loaded]') !== null, {
  timeout: 10000,
})

Implicit Waits: Guess what the browser is doing (avoid)

// ❌ BAD - Guessing delay
await page.click('#submit')
await new Promise((resolve) => setTimeout(resolve, 3000)) // Hope it's done

// ❌ BAD - Fixed timeout regardless of state
await page.waitForTimeout(5000) // Might wait too long or not enough

// ❌ BAD - Assumed state
await page.click('#submit')
const nextButton = await page.$('#next') // Might not exist yet

Pattern: Wait for Success Indicator

Instead of waiting fixed time, wait for actual success:

// ❌ BAD - Hope post completes in 10 seconds
await page.click('#post')
await page.waitForTimeout(10000)

// ✅ GOOD - Wait for actual success message
await page.click('#post')
await page.waitForSelector('[aria-label="Success notification"]', {
  visible: true,
  timeout: 30000,
})

// ✅ BEST - Wait for success AND verify URL change
const postClickPromise = page.click('#post')
const successPromise = page.waitForSelector('[data-success="true"]')
const urlPromise = page.waitForNavigation()

await Promise.all([postClickPromise, successPromise, urlPromise])

Pattern: Wait for Multiple Conditions

Complex workflows often need multiple conditions:

// Upload form scenario
await Promise.all([
  // Wait for upload progress to appear
  page.waitForSelector('[aria-label="Uploading"]'),

  // Wait for file input to be filled
  page.waitForFunction(() => {
    const input = document.querySelector('input[type="file"]')
    return input && input.files.length > 0
  }),

  // Wait for submit button to be enabled
  page.waitForSelector('button:not([disabled])'),
])

// All conditions must be met before continuing

Anti-Pattern: waitForTimeout

// ❌ AVOID - Unreliable magic numbers
await page.click('#submit')
await page.waitForTimeout(5000) // Why 5 seconds? What if it takes 6?

// ❌ AVOID - No verification
await page.type('#email', '[email protected]')
await page.waitForTimeout(1000)
await page.type('#password', 'password') // Did email input work?

Pattern: Exponential Backoff for Polling

For conditions that take unpredictable time:

async function waitForWithBackoff(
  condition: () => boolean,
  maxAttempts = 10,
  baseDelay = 1000
) {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      if (await condition()) {
        return true
      }
    } catch (error) {
      // Condition might throw, try again
    }

    const delay = baseDelay * Math.pow(2, attempt - 1)
    console.log(`Attempt ${attempt}/${maxAttempts}, retrying in ${delay}ms`)
    await page.waitForTimeout(delay)
  }

  throw new Error(`Condition not met after ${maxAttempts} attempts`)
}

// Usage
await waitForWithBackoff(
  async () => {
    const text = await page.$eval('[data-status]', el => el.textContent)
    return text === 'complete'
  },
  15, // max attempts
  1000 // 1 second base delay
)

Resource Cleanup Patterns

Pattern: Always Cleanup in finally Block

// ❌ WRONG - Browser might leak if exception thrown
await page.goto('https://example.com')
await page.click('#button')
await browser.close() // Never reached if click fails

// ✅ CORRECT - Guaranteed cleanup
let browser
try {
  browser = await puppeteer.launch()
  const page = await browser.newPage()
  await page.goto('https://example.com')
  await page.click('#button')
} finally {
  if (browser) {
    await browser.close()
  }
}

Pattern: Track and Close All Pages

// Track opened pages
const pages = []

async function safeNavigate(url) {
  const page = await browser.newPage()
  pages.push(page)

  try {
    await page.goto(url)
    return page
  } catch (error) {
    await page.close()
    throw error
  }
}

// Cleanup all pages
async function cleanup() {
  await Promise.all(pages.map((page) => page.close()))
  pages.length = 0
}

// Usage
try {
  const page1 = await safeNavigate('https://example.com/page1')
  const page2 = await safeNavigate('https://example.com/page2')
  // Do work
} finally {
  await cleanup()
}

Pattern: Close Connections and Streams

// ❌ WRONG - Streams not closed
const stream = page.screenshot({ stream: true })
// Use stream...
// Stream stays open

// ✅ CORRECT - Always close streams
const stream = page.screenshot({ stream: true })
try {
  // Use stream
  const chunks = []
  for await (const chunk of stream) {
    chunks.push(chunk)
  }
} finally {
  await stream.destroy()
}

Pattern: Profile Cleanup for Multilogin

async function scrapeWithProfile(profileId, folderId) {
  let browser
  let profile

  try {
    // Launch profile
    profile = await startProfile(profileId, folderId)
    browser = await puppeteer.connect({
      browserURL: profile.browserUrl,
    })

    // Do work
    const data = await scrapeData(browser)

    return data
  } catch (error) {
    console.error('Scraping failed:', error)
    throw error
  } finally {
    // Always cleanup
    if (browser) {
      try {
        await browser.close()
      } catch (closeError) {
        console.error('Failed to close browser:', closeError)
      }
    }

    if (profile) {
      try {
        await stopProfile(profileId)
      } catch (stopError) {
        console.error('Failed to stop profile:', stopError)
      }
    }
  }
}

Anti-Pattern: Nested try-catch Without finally

// ❌ WRONG - Cleanup might not run
try {
  const browser = await puppeteer.launch()
  try {
    const page = await browser.newPage()
    await page.goto('https://example.com')
  } catch (error) {
    console.error('Page error:', error)
  }
  // browser.close() only runs if no exception
  await browser.close()
}

// ✅ CORRECT - Single finally for all cleanup
let browser
let page
try {
  browser = await puppeteer.launch()
  page = await browser.newPage()
  await page.goto('https://example.com')
} catch (error) {
  console.error('Error:', error)
} finally {
  if (page) await page.close()
  if (browser) await browser.close()
}

Session Management

Pattern: Use Distributed Locks per Account

import { redlock } from 'src/lib/redlock'

async function processAccount(accountId, task) {
  const lockKey = `facebook-account-${accountId}`
  const lockTTL = 600000 // 10 minutes

  let lock
  try {
    // Acquire lock
    lock = await redlock.acquire([lockKey], lockTTL)

    // Only one worker processes this account
    const profile = await getProfile(accountId)
    await task(profile)
  } catch (error) {
    if (error.message.includes('Lock')) {
      console.warn(`Account ${accountId} is locked, skipping`)
      return // Another worker is processing it
    }
    throw error
  } finally {
    // Always release lock
    if (lock) {
      await lock.release()
    }
  }
}

Pattern: Session Expiry and Renewal

const SESSION_MAX_AGE = 30 * 60 * 1000 // 30 minutes

async function getActiveSession(accountId) {
  // Check cached session
  const cached = await cache.get(`session:${accountId}`)
  if (cached) {
    const age = Date.now() - cached.createdAt
    if (age < SESSION_MAX_AGE) {
      return cached // Session still valid
    }
    // Session expired
    console.log(`Session for ${accountId} expired`)
    await cache.del(`session:${accountId}`)
  }

  // Create new session
  const session = await createNewSession(accountId)
  await cache.set(`session:${accountId}`, session, { ttl: SESSION_MAX_AGE })
  return session
}
// Save cookies after authentication
async function authenticateAndSaveCookies(accountId) {
  const browser = await puppeteer.launch()
  const page = await browser.newPage()

  // Authenticate
  await page.goto('https://facebook.com/login')
  await page.type('#email', account.email)
  await page.type('#pass', account.password)
  await page.click('#loginbutton')
  await page.waitForNavigation()

  // Save cookies
  const cookies = await page.cookies()
  await db.cookies.upsert({
    accountId,
    data: JSON.stringify(cookies),
    updatedAt: new Date(),
  })

  await browser.close()
}

// Restore cookies on next use
async function restoreCookies(accountId, page) {
  const cookieData = await db.cookies.findUnique({
    where: { accountId },
  })

  if (cookieData) {
    const cookies = JSON.parse(cookieData.data)
    await page.setCookie(...cookies)
    await page.goto('https://facebook.com') // Cookies auto-applied
  }
}

Anti-Pattern: Sharing Pages Between Tasks

// ❌ WRONG - Page state leaks between tasks
const page = await browser.newPage()

async function task1() {
  await page.goto('https://example.com/task1')
  await page.click('#button')
  // Page left at task1's state
}

async function task2() {
  await page.goto('https://example.com/task2')
  // Unexpected state from task1
  // Cookies, localStorage, URL bar affected
}

// ✅ CORRECT - Fresh page per task
async function taskWithFreshPage() {
  const page = await browser.newPage()
  try {
    await page.goto('https://example.com/task1')
    await page.click('#button')
  } finally {
    await page.close()
  }
}

Debugging Techniques

Pattern: Store Raw HTML for Debugging

async function scrapeWithDebug(page, accountId) {
  const startTime = Date.now()

  try {
    // Scrape data
    const data = await page.evaluate(() => {
      return {
        title: document.querySelector('h1')?.textContent,
        price: document.querySelector('[data-price]')?.textContent,
      }
    })

    return data
  } catch (error) {
    // Capture full state when error occurs
    const html = await page.content()
    const screenshot = await page.screenshot({ fullPage: true })

    // Store for debugging
    await db.debugLog.create({
      accountId,
      error: error.message,
      html,
      screenshot,
      timestamp: new Date(),
      url: page.url(),
      duration: Date.now() - startTime,
    })

    console.error('Scraping failed, debug ID:', debugLog.id)
    throw error
  }
}

Pattern: Screenshot on Every Step

async function scrapeWithScreenshots(page, sessionId) {
  let step = 0

  const screenshot = async (name) => {
    const path = `debug/${sessionId}-${step}-${name}.png`
    await page.screenshot({ path, fullPage: true })
    step++
    console.log(`Screenshot: ${path}`)
  }

  try {
    await page.goto('https://example.com')
    await screenshot('initial')

    await page.click('#button')
    await screenshot('clicked-button')

    await page.waitForSelector('#result')
    await screenshot('result-loaded')

    return data
  } catch (error) {
    await screenshot('error')
    throw error
  }
}

Pattern: Verbose Logging with Context

class ScraperLogger {
  constructor(accountId, sessionId) {
    this.accountId = accountId
    this.sessionId = sessionId
  }

  log(message, context = {}) {
    console.log({
      timestamp: new Date().toISOString(),
      accountId: this.accountId,
      sessionId: this.sessionId,
      message,
      ...context,
    })
  }

  async logPageState(page, message) {
    const state = await page.evaluate(() => ({
      url: window.location.href,
      title: document.title,
      visible: document.visibilityState,
    }))
    this.log(message, { pageState: state })
  }
}

// Usage
const logger = new ScraperLogger(accountId, sessionId)

await page.goto('https://example.com')
logger.logPageState(page, 'Navigation complete')

await page.click('#button')
logger.log('Clicked button', { selector: '#button' })

Pattern: Console Logging in Browser Context

// Add console listeners before page loads
page.on('console', (msg) => {
  if (msg.type() === 'error') {
    console.error('Browser console error:', msg.text())
  }
})

page.on('pageerror', (error) => {
  console.error('Page error:', error.toString())
})

// Navigate and capture JavaScript errors
await page.goto('https://example.com')

Pattern: Step-by-Step Execution for AI Agents

async function executeAgentTask(agent, task) {
  const startTime = Date.now()
  const steps = []

  // Log each step
  const logStep = (step, tool, input, output) => {
    steps.push({ step, tool, input, output, timestamp: Date.now() })
    console.log(`Step ${step}: ${tool}(${JSON.stringify(input)})`)
    console.log(`${output}`)
  }

  try {
    const result = await agent.executeTask(task, {
      callbacks: [
        {
          handleToolStart: (tool, input) => {
            logStep(steps.length + 1, tool.name, input)
          },
          handleToolEnd: (output) => {
            if (steps.length > 0) {
              steps[steps.length - 1].output = String(output)
            }
          },
        },
      ],
    })

    const duration = Date.now() - startTime

    // Save execution trace
    await db.agentExecution.create({
      task,
      steps,
      duration,
      success: result.success,
      timestamp: new Date(),
    })

    return result
  } catch (error) {
    // Save failed execution
    await db.agentExecution.create({
      task,
      steps,
      error: error.message,
      timestamp: new Date(),
    })
    throw error
  }
}

Anti-Pattern: Silent Failures

// ❌ WRONG - Errors swallowed
async function scrape() {
  try {
    await page.goto('https://example.com')
    const data = await page.evaluate(() => document.querySelector('h1').textContent)
    return data
  } catch (error) {
    // Silent - no one knows it failed
    return null
  }
}

// ✅ CORRECT - Log and propagate
async function scrape() {
  try {
    await page.goto('https://example.com')
    const data = await page.evaluate(() => document.querySelector('h1').textContent)
    return data
  } catch (error) {
    console.error('Scraping failed:', {
      error: error.message,
      url: page.url(),
      stack: error.stack,
    })
    throw error // Propagate to caller
  }
}

Anti-Patterns Summary

Selector Anti-Patterns

Anti-PatternWhy It's BadBetter Alternative
Obfuscated classes (.x1heo9i)Changes weeklyARIA attributes ([role="heading"])
Hardcoded paths (div > div > span)Brittle to DOM changesFlexible selectors with fallbacks
No wait before clickRace conditionwaitForSelector({ visible: true })
Single selector strategyFails if structure changesMultiple fallback selectors

Wait Anti-Patterns

Anti-PatternWhy It's BadBetter Alternative
waitForTimeout(5000)Magic number, no verificationwaitForSelector() with specific element
No wait after navigationElement might not existwaitForNavigation() or waitForFunction()
Assume state (no verification)Race conditionsWait for actual success indicator

Cleanup Anti-Patterns

Anti-PatternWhy It's BadBetter Alternative
No finally blockResources leak on errorAlways cleanup in finally
Nested try-catchCleanup might not runSingle finally for all resources
Unclosed streamsMemory leaksfinally { await stream.destroy() }

Session Anti-Patterns

Anti-PatternWhy It's BadBetter Alternative
No account lockingConcurrent operationsDistributed lock per account
No session expiryStale cookies cause failuresCheck and renew sessions
Shared page stateLeaks between tasksFresh page per task

Testing Patterns

Pattern: Visual Regression Tests

import { diff } from 'pixelmatch'

async function testVisual(page, testName) {
  // Capture current screenshot
  const screenshot = await page.screenshot()

  // Compare with baseline
  const baseline = await fs.readFile(`baselines/${testName}.png`)
  const diff = diff(baseline, screenshot, { threshold: 0.1 })

  if (diff) {
    await fs.writeFile(`diffs/${testName}-diff.png`, diff)
    throw new Error(`Visual regression detected for ${testName}`)
  }
}

Pattern: Selector Stability Tests

async function testSelectorStability(url, selector, retries = 10) {
  const results = []

  for (let i = 0; i < retries; i++) {
    const browser = await puppeteer.launch()
    const page = await browser.newPage()

    const element = await page.$(selector)
    results.push(!!element)

    await browser.close()
  }

  const successRate = results.filter((r) => r).length / retries

  if (successRate < 0.8) {
    console.warn(`Selector ${selector} only ${successRate * 100}% reliable`)
  }

  return successRate
}

My Production Setup

After running 100+ browser profiles for months, here's what actually works:

Stack:

  • Multilogin — Isolated browser profiles with unique fingerprints
  • Puppeteer — Core automation (considering Playwright for new projects)
  • Redis + Redlock — Distributed locks per account
  • PostgreSQL — Session/cookie persistence, debug logs

Key Metrics:

  • Selector fallback rate: ~15% (fallbacks trigger when primary fails)
  • Session reuse rate: ~80% (avoid re-auth when possible)
  • Error screenshot capture: 100% (never debug blind)
  • Uptime: 99.2% after implementing these patterns

Cost breakdown (monthly):

  • Multilogin: ~$100 (100 profiles)
  • Proxy bandwidth: ~$50
  • Redis/DB hosting: ~$20
  • Total: ~$170/month for production-grade automation

Lessons learned:

  1. Log selector usage — Know which fallbacks fire most often
  2. Rotate user agents weekly — Even with Multilogin
  3. Human-like delays — Random 1-3s between actions
  4. Screenshot on success too — Not just errors (helps verify)

Final Thoughts

Robust browser automation requires:

  1. Flexible selectors — Fallback strategies, not hardcode
  2. Explicit waits — Wait for conditions, not time
  3. Guaranteed cleanupfinally blocks, resource tracking
  4. Isolated sessions — Distributed locks, fresh pages
  5. Comprehensive debugging — HTML capture, screenshots, logging

The difference between "works on my machine" and "works in production" is deliberate patterns for handling failure cases.

These patterns took me months to learn the hard way. Hopefully this guide saves you some weekends.


Resources