Ever needed to automatically generate screenshots of web pages? Maybe for social media previews, monitoring, or just because manually screenshotting 50 URLs sounds like torture.
I built this exact API for a client who needed to generate previews of user-submitted websites. Here’s how to build your own serverless screenshot service that can handle thousands of requests without breaking the bank.
Why Lambda + Puppeteer?#
Before we dive in, let’s talk about why this architecture makes sense:
- No server management - Lambda handles scaling automatically
- Pay per use - Only charged when someone requests a screenshot
- Fast cold starts - With proper optimization, sub-3 second responses
- Built-in retry logic - Lambda handles failures gracefully
The alternative would be running Puppeteer on EC2, but that means paying for idle time and handling your own scaling. Been there, done that - Lambda wins for this use case.
What We’re Building#
Our screenshot API will:
- Accept a URL via HTTP POST
- Launch a headless Chrome browser using Puppeteer
- Take a screenshot and save it to S3
- Return a signed URL for accessing the image
- Handle errors gracefully (invalid URLs, timeouts, etc.)
Final API usage will look like:
1
2
3
| curl -X POST https://your-api.com/screenshot \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com", "width": 1200, "height": 800}'
|
Response:
1
2
3
4
5
6
7
8
9
| {
"success": true,
"imageUrl": "https://s3.amazonaws.com/your-bucket/screenshot.png?signature=...",
"metadata": {
"originalUrl": "https://example.com",
"dimensions": "1200x800",
"timestamp": "2025-08-01T10:00:00Z"
}
}
|
Prerequisites#
Before we start building, make sure you have:
Project Setup#
Let’s start by creating our project structure:
1
2
3
4
5
6
| mkdir screenshot-api
cd screenshot-api
# Create the basic structure
mkdir src layers
touch template.yaml package.json
|
Your project should look like this:
1
2
3
4
5
| screenshot-api/
├── src/
├── layers/
├── template.yaml
└── package.json
|
Creating the Lambda Layer#
Puppeteer is a chunky dependency. Instead of bundling it with every Lambda function, we’ll create a Lambda Layer that can be reused.
Create layers/puppeteer/package.json
:
1
2
3
4
5
6
7
8
| {
"name": "puppeteer-layer",
"version": "1.0.0",
"dependencies": {
"puppeteer-core": "^21.3.6",
"@sparticuz/chromium": "^118.0.0"
}
}
|
Why puppeteer-core
instead of regular puppeteer
? The core version doesn’t download Chrome automatically - we’ll use the @sparticuz/chromium
package which provides a Lambda-optimized Chrome binary.
Install the dependencies:
1
2
3
| cd layers/puppeteer
npm install
cd ../..
|
The Main Lambda Function#
Now for the meat of our API. Create src/screenshot/index.js
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
| const puppeteer = require('puppeteer-core');
const chromium = require('@sparticuz/chromium');
const AWS = require('aws-sdk');
const s3 = new AWS.S3();
const BUCKET_NAME = process.env.BUCKET_NAME;
exports.handler = async (event) => {
console.log('Event:', JSON.stringify(event, null, 2));
try {
// Parse the request body
const body = JSON.parse(event.body || '{}');
const { url, width = 1200, height = 800, fullPage = false } = body;
// Validate URL
if (!url || !isValidUrl(url)) {
return {
statusCode: 400,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
success: false,
error: 'Invalid or missing URL'
})
};
}
// Launch Puppeteer
console.log('Launching browser...');
const browser = await puppeteer.launch({
args: chromium.args,
defaultViewport: chromium.defaultViewport,
executablePath: await chromium.executablePath(),
headless: chromium.headless,
ignoreHTTPSErrors: true,
});
const page = await browser.newPage();
// Set viewport
await page.setViewport({ width: parseInt(width), height: parseInt(height) });
// Navigate to URL with timeout
console.log(`Navigating to: ${url}`);
await page.goto(url, {
waitUntil: 'networkidle0',
timeout: 30000
});
// Take screenshot
console.log('Taking screenshot...');
const screenshot = await page.screenshot({
type: 'png',
fullPage: fullPage
});
await browser.close();
// Generate unique filename
const timestamp = new Date().toISOString().replace(/[:.]/g, '_');
const urlHash = Buffer.from(url).toString('base64').substring(0, 8);
const fileName = `screenshots/${timestamp}_${urlHash}.png`;
// Upload to S3
console.log(`Uploading to S3: ${fileName}`);
const uploadParams = {
Bucket: BUCKET_NAME,
Key: fileName,
Body: screenshot,
ContentType: 'image/png',
Metadata: {
originalUrl: url,
dimensions: `${width}x${height}`,
timestamp: new Date().toISOString()
}
};
await s3.upload(uploadParams).promise();
// Generate signed URL (valid for 24 hours)
const signedUrl = s3.getSignedUrl('getObject', {
Bucket: BUCKET_NAME,
Key: fileName,
Expires: 86400 // 24 hours
});
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify({
success: true,
imageUrl: signedUrl,
metadata: {
originalUrl: url,
dimensions: `${width}x${height}`,
timestamp: new Date().toISOString(),
fileName: fileName
}
})
};
} catch (error) {
console.error('Error:', error);
return {
statusCode: 500,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
success: false,
error: error.message || 'Internal server error'
})
};
}
};
function isValidUrl(string) {
try {
const url = new URL(string);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch (_) {
return false;
}
}
|
Create src/screenshot/package.json
:
1
2
3
4
5
6
7
| {
"name": "screenshot-function",
"version": "1.0.0",
"dependencies": {
"aws-sdk": "^2.1400.0"
}
}
|
SAM Template Configuration#
Now we need to define our infrastructure. Create template.yaml
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
| AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Screenshot API using Lambda and Puppeteer
Globals:
Function:
Timeout: 60
MemorySize: 1024
Runtime: nodejs18.x
Parameters:
BucketName:
Type: String
Default: screenshot-api-bucket-unique-suffix
Description: S3 bucket name for storing screenshots
Resources:
# S3 Bucket for screenshots
ScreenshotBucket:
Type: AWS::S3::Bucket
Properties:
BucketName: !Ref BucketName
LifecycleConfiguration:
Rules:
- Id: DeleteOldScreenshots
Status: Enabled
ExpirationInDays: 30 # Delete screenshots after 30 days
PublicAccessBlockConfiguration:
BlockPublicAcls: true
BlockPublicPolicy: true
IgnorePublicAcls: true
RestrictPublicBuckets: true
# Lambda Layer for Puppeteer
PuppeteerLayer:
Type: AWS::Serverless::LayerVersion
Properties:
LayerName: puppeteer-chromium-layer
Description: Puppeteer with Chromium for Lambda
ContentUri: layers/puppeteer/
CompatibleRuntimes:
- nodejs18.x
RetentionPolicy: Retain
Metadata:
BuildMethod: nodejs18.x
# Lambda Function
ScreenshotFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: screenshot-api
CodeUri: src/screenshot/
Handler: index.handler
Layers:
- !Ref PuppeteerLayer
Environment:
Variables:
BUCKET_NAME: !Ref ScreenshotBucket
Policies:
- S3WritePolicy:
BucketName: !Ref ScreenshotBucket
- S3ReadPolicy:
BucketName: !Ref ScreenshotBucket
Events:
Api:
Type: Api
Properties:
Path: /screenshot
Method: post
Outputs:
ApiUrl:
Description: API Gateway endpoint URL
Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/screenshot"
BucketName:
Description: S3 bucket name
Value: !Ref ScreenshotBucket
|
Deployment#
Install function dependencies:
1
2
3
| cd src/screenshot
npm install
cd ../..
|
Deploy using SAM:
1
2
3
4
5
6
7
8
| # Build the application
sam build
# Deploy with guided setup (first time only)
sam deploy --guided
# For subsequent deployments
sam deploy
|
During guided setup, you’ll be asked for:
- Stack Name:
screenshot-api-stack
- AWS Region: Choose your preferred region
- BucketName: Enter a globally unique bucket name
- Confirm changes: Y
- Allow SAM to create IAM roles: Y
- Save parameters: Y
Testing the API#
Once deployed, test your API:
1
2
3
4
5
6
7
8
9
10
11
| # Get the API URL from the deployment output
API_URL="https://your-api-gateway-url.amazonaws.com/Prod/screenshot"
# Test with a simple website
curl -X POST $API_URL \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com",
"width": 1200,
"height": 800
}' | jq
|
You should get a response like:
1
2
3
4
5
6
7
8
9
10
| {
"success": true,
"imageUrl": "https://s3.amazonaws.com/your-bucket/screenshots/2025-08-01T10_00_00_000Z_aHR0cHM6.png?AWSAccessKeyId=...",
"metadata": {
"originalUrl": "https://example.com",
"dimensions": "1200x800",
"timestamp": "2025-08-01T10:00:00.000Z",
"fileName": "screenshots/2025-08-01T10_00_00_000Z_aHR0cHM6.png"
}
}
|
Advanced Features#
Custom Wait Conditions#
Sometimes you need to wait for specific content to load:
1
2
3
4
5
6
7
8
| // Wait for a specific element
await page.waitForSelector('.main-content', { timeout: 10000 });
// Wait for JavaScript to finish (useful for SPAs)
await page.waitForFunction(() => window.appReady === true, { timeout: 15000 });
// Wait for network requests to finish
await page.goto(url, { waitUntil: 'networkidle2' });
|
Mobile Screenshots#
Add mobile viewport support:
1
2
3
4
5
6
7
8
9
10
11
12
| const devices = {
mobile: { width: 375, height: 667, isMobile: true },
tablet: { width: 768, height: 1024, isMobile: true },
desktop: { width: 1200, height: 800, isMobile: false }
};
const device = devices[body.device] || devices.desktop;
await page.setViewport(device);
if (device.isMobile) {
await page.setUserAgent('Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X)');
}
|
PDF Generation#
Bonus feature - generate PDFs instead of images:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| // In your Lambda function
const pdf = await page.pdf({
format: 'A4',
printBackground: true,
margin: { top: '20px', bottom: '20px', left: '20px', right: '20px' }
});
// Upload to S3 with different content type
const uploadParams = {
Bucket: BUCKET_NAME,
Key: fileName.replace('.png', '.pdf'),
Body: pdf,
ContentType: 'application/pdf'
};
|
Troubleshooting Common Issues#
Memory Issues#
If you get out of memory errors:
1
2
3
4
5
6
| # In template.yaml, increase memory
ScreenshotFunction:
Type: AWS::Serverless::Function
Properties:
MemorySize: 2048 # Increase from 1024
Timeout: 90 # Increase timeout too
|
Timeout Problems#
For slow-loading sites:
1
2
3
4
5
6
7
8
9
| // Increase navigation timeout
await page.goto(url, {
waitUntil: 'domcontentloaded', // Don't wait for all resources
timeout: 45000 // 45 seconds
});
// Set page timeouts
await page.setDefaultTimeout(30000);
await page.setDefaultNavigationTimeout(45000);
|
Chrome Launch Failures#
If Chromium won’t start:
1
2
3
4
5
6
7
8
9
10
11
| const browser = await puppeteer.launch({
args: [
...chromium.args,
'--disable-gpu',
'--disable-dev-shm-usage',
'--disable-software-rasterizer',
'--no-first-run'
],
executablePath: await chromium.executablePath(),
headless: chromium.headless,
});
|
Cost Optimization#
Here’s what this setup costs and how to optimize:
Typical Costs (us-east-1)#
- Lambda: $0.0000166667 per GB-second (1GB for 30s = ~$0.0005 per screenshot)
- API Gateway: $3.50 per million requests + $0.09 per GB data transfer
- S3: $0.023 per GB storage + $0.0004 per 1000 requests
Example: 10,000 screenshots/month ≈ $12-15 total
Optimization Tips#
Adjust memory based on usage:
1
2
3
4
| # Monitor memory usage
aws logs filter-log-events \
--log-group-name /aws/lambda/screenshot-api \
--filter-pattern "Max Memory Used"
|
Implement caching:
1
2
3
4
5
6
7
8
| // Check if screenshot already exists
const cacheKey = crypto.createHash('md5').update(url + width + height).digest('hex');
try {
await s3.headObject({ Bucket: BUCKET_NAME, Key: `cache/${cacheKey}.png` }).promise();
// Return existing screenshot
} catch (err) {
// Take new screenshot
}
|
Batch processing:
1
2
3
4
5
| // Accept multiple URLs in one request
const { urls } = body;
const results = await Promise.all(urls.map(async (url) => {
// Process each URL
}));
|
Security Considerations#
Never trust user input. Add these safeguards:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| // URL validation
function isValidUrl(url) {
try {
const urlObj = new URL(url);
// Block internal/private networks
if (urlObj.hostname === 'localhost' ||
urlObj.hostname.startsWith('192.168.') ||
urlObj.hostname.startsWith('10.') ||
urlObj.hostname.endsWith('.local')) {
return false;
}
return urlObj.protocol === 'http:' || urlObj.protocol === 'https:';
} catch (_) {
return false;
}
}
// Size limits
if (width > 2560 || height > 1440) {
throw new Error('Dimensions too large');
}
|
Rate Limiting#
Add rate limiting with API Gateway:
1
2
3
4
5
6
7
| # In template.yaml
Api:
Type: Api
Properties:
ThrottleConfig:
RateLimit: 100 # requests per second
BurstLimit: 200 # burst capacity
|
Or implement custom rate limiting with DynamoDB:
1
2
3
4
5
6
| // Check rate limit before processing
const rateLimitKey = event.requestContext.identity.sourceIp;
const current = await checkRateLimit(rateLimitKey);
if (current > 10) { // 10 requests per minute
return { statusCode: 429, body: 'Rate limit exceeded' };
}
|
Monitoring and Alerting#
Set up CloudWatch alarms:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| # Add to template.yaml
ErrorAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: ScreenshotAPIErrors
AlarmDescription: High error rate for screenshot API
MetricName: Errors
Namespace: AWS/Lambda
Statistic: Sum
Period: 300
EvaluationPeriods: 2
Threshold: 5
ComparisonOperator: GreaterThanThreshold
Dimensions:
- Name: FunctionName
Value: !Ref ScreenshotFunction
|
Next Steps#
You now have a production-ready screenshot API! Here are some ideas to extend it:
- Add authentication with API keys or JWT tokens
- Implement webhooks for async processing
- Add image optimization with Sharp or similar
- Create a simple frontend for testing
- Add support for custom CSS injection
Useful References#
The beauty of this setup is that it scales from zero to thousands of requests automatically. No servers to manage, no complex deployments - just working code that does exactly what you need.
Built something cool with this API? I’d love to hear about it! Share your projects @TheLogicalDev.