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:

  1. Accept a URL via HTTP POST
  2. Launch a headless Chrome browser using Puppeteer
  3. Take a screenshot and save it to S3
  4. Return a signed URL for accessing the image
  5. 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

  1. 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"
    
  2. 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
    }
    
  3. 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:

  1. Add authentication with API keys or JWT tokens
  2. Implement webhooks for async processing
  3. Add image optimization with Sharp or similar
  4. Create a simple frontend for testing
  5. 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.