Building a Screenshot Worker with Andera and Playwright
Introduction
Taking website screenshots at scale is a common need for monitoring, reporting, or automation. But orchestrating browser instances, managing concurrency, and ensuring reliability can quickly become complex. This article shows how Andera and Playwright make it simple and robust, using the Screenshot Worker as a real-world example.
📚 New to Andera? See the Base Worker Introduction to understand the core concepts and architecture.
Why Andera?
Andera is a Task Orchestration Platform (TOP) designed for performance, simplicity, and flexibility. It is technology-agnostic, supports both standalone and clustered deployments, and provides:
- Native slot management for parallelism
- Built-in maintenance mode for safe updates
- Secure authentication and best practices
- Auto-discovery of functions, services, and helpers
Architecture of the Screenshot Worker
The Screenshot Worker leverages several Andera features:
- Service: A Playwright service launches a Chromium browser and manages a pool of isolated browser contexts (slots), ready to take screenshots in parallel. (Learn more)
- Function: A
screenshot
function receives a URL, picks a free context, navigates, captures the screenshot, and resets the context. (Learn more) - Helper: Helpers manage context allocation and release, ensuring efficient resource usage.
- Slots: The Worker exposes a configurable number of slots (e.g., 10), each mapped to a browser context. (Usage details)
How It Works: Step by Step
-
Service Initialization:
- On startup, the Playwright service launches a headless Chromium browser and creates a configurable number of browser contexts (slots).
- Each context is tracked as either 'free' or 'busy'.
-
Handling a Screenshot Request:
- The
screenshot
function is called with a URL and optional parameters (width, height, returnType). - It validates the URL, finds a free context using a helper, and marks it as busy.
- The context opens a new page, navigates to the URL, takes a screenshot, and returns the result (as base64 or binary string).
- The page is closed and the context is reset for the next request.
- The
-
Context Management:
- Helpers (
getFreeContext
,releaseContext
) manage the allocation and release of contexts, ensuring efficient parallelism and recovery from errors.
- Helpers (
Code: Playwright Service
import { defineService } from '@andera-top/worker-core'
import { chromium, Browser, BrowserContext, Page } from 'playwright'
import { config } from '../config'
import { log, warn, error } from '@andera-top/worker-core/dist/utils/logger'
// Playwright browser instance (shared)
let browser: Browser | null = null
// Context pool and status tracking
export const contexts: BrowserContext[] = []
export const contextStatus: ('free' | 'busy')[] = []
export const contextBusySince: (number | null)[] = []
export let browserVersion: string | null = null
export default defineService({
config: { restartOnFailure: true },
start: async () => {
try {
// Launch Chromium (Chrome for Testing)
browser = await chromium.launch({ headless: true })
// Create the context pool (slots)
for (let i = 0; i < config.worker.slots; i++) {
const context = await browser.newContext()
contexts.push(context)
contextStatus.push('free')
contextBusySince.push(null)
}
browserVersion = browser.version()
log('[PLAYWRIGHT]', `${config.worker.slots} contexts opened and ready`)
// Periodically force-release contexts that are stuck as busy for too long
setInterval(() => {
const now = Date.now()
const timeoutLimit = config.worker.defaultTimeout ?? 30000
for (let i = 0; i < contextStatus.length; i++) {
if (contextStatus[i] === 'busy' && typeof contextBusySince[i] === 'number' && contextBusySince[i] !== null && Number.isFinite(contextBusySince[i])) {
if (now - contextBusySince[i]! > timeoutLimit) {
warn('[PLAYWRIGHT]', `Force-released context ${i} after ${now - contextBusySince[i]!}ms busy (since=${contextBusySince[i]!}, now=${now})`)
contextStatus[i] = 'free'
contextBusySince[i] = null
}
}
}
}, 10000)
} catch (err: any) {
error('[PLAYWRIGHT]', 'Failed to launch Playwright/Chromium:', err && err.stack ? err.stack : err)
throw err
}
},
stop: async () => {
if (browser) await browser.close()
browser = null
contexts.length = 0
contextStatus.length = 0
contextBusySince.length = 0
log('[PLAYWRIGHT]', 'Closed')
},
status: async () => ({
browser: !!browser,
freeContexts: contextStatus.filter(s => s === 'free').length,
busyContexts: contextStatus.filter(s => s === 'busy').length,
}),
}) as any
// Expose the browser for helpers
Object.defineProperty(exports.default, 'browser', {
get() {
return browser
},
enumerable: false,
})
Code: Screenshot Function
import { defineFunction } from '@andera-top/worker-core'
import { getFreeContext, releaseContext } from '../helpers/playwrightPool'
import { log, warn, error } from '@andera-top/worker-core/dist/utils/logger'
import { URL } from 'url'
// Validate that the provided URL is well-formed and uses http or https
function validateUrlBasic(urlString: string) {
let url
try {
url = new URL(urlString)
} catch {
throw new Error('[SCREENSHOT] Invalid URL')
}
if (!['http:', 'https:'].includes(url.protocol)) {
throw new Error('[SCREENSHOT] Only http and https are allowed')
}
}
// Format the screenshot result according to the requested returnType
function formatScreenshotResult(buffer: unknown, returnType: string) {
if (returnType === 'binary') {
if (Buffer.isBuffer(buffer)) {
return { screenshot: buffer.toString('binary') }
} else if (typeof buffer === 'string') {
return { screenshot: Buffer.from(buffer, 'base64').toString('binary') }
}
} else if (returnType === 'base64') {
if (typeof buffer === 'string') {
return { screenshot: buffer }
} else if (Buffer.isBuffer(buffer)) {
return { screenshot: buffer.toString('base64') }
}
}
throw new Error('Unexpected screenshot buffer type or returnType')
}
export const screenshot = defineFunction({
params: {
url: { type: 'string', required: true },
width: { type: 'number', required: false, default: 1280 },
height: { type: 'number', required: false, default: 720 },
returnType: { type: 'string', required: false, default: 'base64' },
waitForSelector: { type: 'string', required: false },
imageMimeType: { type: 'string', required: false, default: 'image/jpeg', enum: ['image/png', 'image/jpeg'] },
quality: { type: 'number', required: false },
delay: { type: 'number', required: false },
},
config: {
timeout: 30000,
logResult: false,
},
handler: async (params, context) => {
// Extract and validate input parameters
const { url, width, height, returnType = 'base64', waitForSelector, imageMimeType = 'image/jpeg', quality, delay } = params
validateUrlBasic(url)
// Determine the image type for Playwright ('png' or 'jpeg')
let imageType: 'png' | 'jpeg' = imageMimeType === 'image/jpeg' ? 'jpeg' : 'png'
log(
'[SCREENSHOT]',
`Taking screenshot: url=${url}, width=${width}, height=${height}, imageType=${imageType}, waitForSelector=${waitForSelector}, quality=${quality}, delay=${delay}`
)
// Get a free Playwright context (slot) from the pool
const slot = await getFreeContext()
if (!slot) throw new Error('[SCREENSHOT] No free Playwright context available')
const { context: browserContext, index } = slot
// Set up timeout management
const timeoutMs = screenshot.config?.timeout ?? 30000
let timeoutId: NodeJS.Timeout | null = null
let finished = false
try {
// Optional delay before taking the screenshot
if (delay && delay > 0) {
await new Promise(resolve => setTimeout(resolve, delay))
}
// Race between the screenshot logic and the global timeout
return await Promise.race([
(async () => {
// Open a new page in the allocated context
const page = await browserContext.newPage()
// Set the viewport size
await page.setViewportSize({ width: Number(width), height: Number(height) })
// Navigate to the target URL
await page.goto(url, { waitUntil: 'networkidle', timeout: timeoutMs })
// Optionally wait for a selector to appear
if (waitForSelector) {
await page.waitForSelector(waitForSelector, { timeout: timeoutMs })
}
// Build screenshot options
let options: any = { type: imageType }
// Quality is only used for JPEG
if (imageType === 'jpeg' && typeof quality === 'number') {
options.quality = quality
}
let buffer: unknown
try {
// Take the screenshot with a timeout
buffer = await Promise.race([
page.screenshot({ ...options, encoding: returnType === 'binary' ? 'binary' : 'base64' }),
new Promise((_, reject) => setTimeout(() => reject(new Error('screenshot() timeout')), timeoutMs)),
])
finished = true
await page.close()
// Format and return the screenshot result
return formatScreenshotResult(buffer, returnType)
} catch (err) {
error('[SCREENSHOT]', `Error during page.screenshot() (${returnType}):`, err)
await page.close()
throw err
}
})(),
// Global timeout for the whole function
new Promise((_, reject) => {
timeoutId = setTimeout(async () => {
if (!finished) {
warn('[SCREENSHOT]', `Timeout reached for url=${url}, width=${width}, height=${height} (slot ${index}) - releasing context`)
await releaseContext(index)
}
reject(new Error('[SCREENSHOT] Screenshot timeout'))
}, timeoutMs)
}),
])
} finally {
if (timeoutId) clearTimeout(timeoutId)
// Always release the context (slot) after use
await releaseContext(index)
}
},
})
Code: Playwright Context Pool Helper
import { contexts, contextStatus, contextBusySince } from '../services/playwright'
import { log, warn } from '@andera-top/worker-core/dist/utils/logger'
import type { BrowserContext } from 'playwright'
// Track which slots are being released to avoid race conditions
const releaseInProgress = new Set<number>()
// Try to get a free Playwright context (slot) from the pool
export async function getFreeContext(): Promise<{ context: BrowserContext; index: number } | null> {
for (let i = 0; i < contexts.length; i++) {
if (contextStatus[i] === 'free') {
contextStatus[i] = 'busy'
contextBusySince[i] = Date.now()
return { context: contexts[i], index: i }
}
}
return null
}
// Release a Playwright context (slot) after use
export async function releaseContext(index: number) {
if (releaseInProgress.has(index)) {
warn('[PLAYWRIGHT]', `releaseContext already in progress for context ${index}, skipping.`)
return
}
releaseInProgress.add(index)
if (contextStatus[index] === 'busy') {
try {
await contexts[index].clearCookies()
await contexts[index].clearPermissions()
} catch (e) {
warn('[PLAYWRIGHT]', `Failed to clean context ${index}: ${e}`)
}
contextStatus[index] = 'free'
contextBusySince[index] = null
}
releaseInProgress.delete(index)
}
Example: Taking a Screenshot via /task
To request a screenshot, send a POST request to the Worker:
{
"function": "screenshot",
"contract": 1,
"mode": "sync",
"input": {
"url": "https://andera.top",
"width": 1920,
"height": 1080,
"returnType": "base64",
"imageMimeType": "image/jpeg",
"quality": 80
},
}
- The Worker will pick a free slot, process the request, and return the image as base64 or binary string.
- You can also use
mode: "webhook"
to receive the result asynchronously.
Example of result:
{
"success": true,
"result": {
"screenshot": "iVBORw0KGgo...VORK5CYII=" // Truncated for the example
}
}
Find this Worker on Github
This repository is available on Github: https://github.com/JulienRamel/andera-screenshot-worker
Further Reading
Conclusion
With Andera and Playwright, building a robust, scalable Screenshot Worker is straightforward. You get modern orchestration, security, and extensibility out of the box so you can focus on your business logic, not infrastructure.
Author: Andera