Case Study: Playwright Survival on AWS Lambda
Can Playwright run reliably inside a serverless container, or does the "serverless everything" trend break down the moment you introduce a full browser engine?
Headless browser automation and serverless compute don't naturally get along. A browser like Chromium expects a real Linux desktop environment underneath it. Lambda adds its own constraints on top: a read-only filesystem outside
/tmp , tight memory ceilings, and a runtime with 300+ MB browser binaries in mind.The Experiment: Flip the Base Image
The hypothesis to test next: instead of forcing AWS tooling onto a generic image, start from AWS's own Lambda Node.js base (Amazon Linux 2023) and add only the browser dependencies Chromium needs. This flips the usual dependency direction. Amazon Linux already speaks Lambda natively, so the only unknown is whether
dnf has what Playwright requires.Setup
mkdir playwright-lambda && cd playwright-lambdanpm init -ynpm install playwrightThe Handler
The handler code needed one adjustment: Chromium's default launch flags assume a sandboxed desktop process model that Lambda's container simply doesn't support. Without disabling the sandbox and forcing single-process mode, the browser process was killed before
launch() even resolved.const { chromium } = require('playwright');
exports.handler = async (event, context) => { let browser = null;
try { console.log("Launching browser..."); browser = await chromium.launch({ headless: true, args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--single-process', // Required for Lambda '--disable-gpu' ] });
const context = await browser.newContext(); const page = await context.newPage();
const targetUrl = event.url || '<url>'; console.log(`Navigating to ${targetUrl}`);
await page.goto(targetUrl, { waitUntil: 'networkidle' });
const pageTitle = await page.title();
const metaDescription = await page.locator('meta[name="description"]').getAttribute('content').catch(() => null);
const topLinks = await page.evaluate(() => { return Array.from(document.querySelectorAll('a')) .map(a => ({ text: a.innerText.trim(), href: a.href })) .filter(link => link.text && link.href && link.href.startsWith('http')) .slice(0, 5); });
// Capture a screenshot (encoded as base64 to return in JSON) // We use JPEG with lower quality to keep the payload size manageable for Lambda const screenshotBuffer = await page.screenshot({ type: 'jpeg', quality: 60 }); const screenshotBase64 = screenshotBuffer.toString('base64');
console.log(`Successfully scraped: ${pageTitle}`);
return { statusCode: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ success: true, url: targetUrl, title: pageTitle, description: metaDescription, topLinks: topLinks, screenshotBase64: screenshotBase64 }), };
} catch (error) { console.error("Error during execution:", error); return { statusCode: 500, body: JSON.stringify({ success: false, error: error.message }) }; } finally { if (browser) { await browser.close(); } }};The Dockerfile
This is where the experiment's core hypothesis gets tested, does
dnf install actually resolve every library Chromium needs?# Use the official AWS Lambda Node.js 20 base imageFROM image/lambda/nodejs:20
# Install system dependencies required by Playwright in Amazon Linux 2023RUN dnf install -y \ atk at-spi2-atk at-spi2-core alsa-lib cups-libs \ gtk3 libdrm mesa-libgbm libxkbcommon libXcomposite \ libXcursor libXdamage libXext libXi libXrandr \ libXScrnSaver libXtst nss nspr pango
COPY package.json index.js ./RUN npm install
ENV PLAYWRIGHT_BROWSERS_PATH=/var/task/ms-playwrightENV PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS=true
RUN npx playwright install chromium
CMD ["index.handler"]Answer: yes, with one caveat.
PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS has to be set explicitly, because Playwright's own host-check script assumes Ubuntu and will refuse to run on Amazon Linux otherwise, even when every actual library is present. That single environment variable was the difference between a working image and a false "unsupported OS" failure.Local Testing (Recommended)
docker run -p 9000:8080 playwright-lambda-image:latestUsing the standard HTTP endpoint used by the AWS Lambda Runtime Interface Emulator (RIE), we can test the function locally.
When we run the Lambda container locally (e.g., using the
docker run command), AWS uses this emulator inside the container to mimic the real AWS Lambda environment. It spins up a local web server (on port 8080, mapped to our local port 9000) so we can test the function using standard HTTP requests.curl -XPOST "<container:port>/2015-03-31/functions/function/invocations" \ -H "Content-Type: application/json" \ -d '{"url":"url"}'Here is the breakdown of what each part of that URL means:
-
/2015-03-31/: The API version. AWS typically uses the release date of an API as its version number. March 31, 2015, is the date AWS officially released the Lambda Invoke API
-
/functions/function/: The path specifying which function to invoke. Because we are just testing a single local container, the emulator doesn't care about the function's actual name, so it just uses the placeholder name "function".
-
/invocations: The specific API action being called. It tells the AWS runtime to trigger the handler function inside the code and pass the payload to it.
Next Steps
index.js is the heart of this Lambda function. Since the infrastructure (Chromium, dependencies, Docker) is now fully working, index.js is exactly where the actual automation or scraping logic should happen.Integrating with the Amazon Ecosystem
Once the local scraping logic is complete, we can leverage other AWS services to build a fully automated, scalable pipeline:
- Amazon ECR (Elastic Container Registry) Push the finished Docker image to an ECR repository. Lambda requires container images to be hosted in ECR before it can deploy them.
- AWS Lambda Configuration Deploy the function using the ECR image. Crucial step: By default, Lambda allocates 128 MB of memory. Headless Chromium will crash instantly. We must go to the function's General Configuration and increase the Memory to at least 2048 MB and the Timeout to at least 1 minute.
- Amazon S3 (Simple Storage Service)
Instead of returning massive base64 strings in the HTTP response, we can update
index.jsusing the AWS SDK (@aws-sdk/client-s3) to upload the captured screenshots or scraped JSON data directly to an S3 bucket for persistent storage.
- Amazon EventBridge (formerly CloudWatch Events) If we want our scraper to run on a schedule (e.g., scrape a competitor's pricing page every morning at 8 AM), we can create an EventBridge Rule with a Cron expression to automatically trigger our Lambda function.
- Amazon API Gateway / Lambda Function URLs If we want to expose our scraper as a public HTTP API that our frontend or other applications can call over the web, we can attach an API Gateway or generate a direct Function URL for our Lambda.
Conclusion
We have successfully built and containerized a Playwright-based web scraper on AWS Lambda using Amazon Linux 2023. The initial hypothesis that we could run a full headless browser inside a minimal Lambda environment by simply installing the correct system dependencies was proven correct.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article