Tutorial

Node.js Screenshot API: Complete Integration Guide

A comprehensive Node.js guide for screenshot API integration. From basic captures to production-ready async processing with webhooks.

Asad AliJanuary 25, 202611 min read

Node.js is one of the most popular environments for building APIs and automation tools. Whether you're building a documentation system, social media tool, or testing pipeline, integrating screenshot capabilities into your Node.js application is straightforward with a REST API.

In this comprehensive guide, we'll cover everything from basic screenshot capture to production-ready implementations with webhooks, queuing, and error handling.

Reference docs for the Node.js APIs we'll use: native Fetch API on Node 18+ (built on undici), AbortController and AbortSignal for cancellation, p-limit for concurrency control, and BullMQ for production queue workers. For Express middleware patterns: the Express docs on writing middleware. For S3 streaming uploads, the AWS SDK v3 S3 docs and the Upload utility helper.

Getting Started

Installation

Create a new Node.js project and install dependencies:

mkdir screenshot-app
cd screenshot-app
npm init -y
npm install express dotenv

Set up your environment:

# .env
SCREENSHOTLY_API_KEY=your_api_key_here
PORT=3000

Basic Screenshot Function

Here's the simplest way to capture a screenshot in Node.js:

// screenshot.js
require('dotenv').config();

const API_KEY = process.env.SCREENSHOTLY_API_KEY;

async function captureScreenshot(url, options = {}) {
  const response = await fetch('https://api.screenshotly.app/screenshot', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      url,
      device: options.device || 'desktop',
      format: options.format || 'png',
      fullPage: options.fullPage || false,
      ...options,
    }),
  });

  if (!response.ok) {
    const error = await response.text();
    throw new Error(`Screenshot failed: ${response.status} - ${error}`);
  }

  return Buffer.from(await response.arrayBuffer());
}

module.exports = { captureScreenshot };

Express API Endpoint

Create an Express server that exposes screenshot functionality:

// server.js
require('dotenv').config();
const express = require('express');
const { captureScreenshot } = require('./screenshot');

const app = express();
app.use(express.json());

// Screenshot endpoint
app.post('/api/screenshot', async (req, res) => {
  const { url, device, format, fullPage } = req.body;

  if (!url) {
    return res.status(400).json({ error: 'URL is required' });
  }

  try {
    const screenshot = await captureScreenshot(url, {
      device,
      format,
      fullPage,
    });

    const contentType = format === 'jpeg' ? 'image/jpeg' : 'image/png';
    res.setHeader('Content-Type', contentType);
    res.send(screenshot);
  } catch (error) {
    console.error('Screenshot error:', error.message);
    res.status(500).json({ error: error.message });
  }
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

Test it:

curl -X POST http://localhost:3000/api/screenshot \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com"}' \
  --output screenshot.png

Advanced Features

AI Element Removal

Remove distracting elements like cookie banners and chat widgets:

async function captureCleanScreenshot(url) {
  const response = await fetch('https://api.screenshotly.app/screenshot', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      url,
      device: 'desktop',
      format: 'png',
      aiRemoval: {
        enabled: true,
        types: ['cookie-banner', 'chat-widget', 'popup', 'notification'],
      },
    }),
  });

  return Buffer.from(await response.arrayBuffer());
}

Device Mockups

Generate marketing-ready screenshots with device frames:

async function captureWithMockup(url, mockupType) {
  // Available types: 'browser-light', 'browser-dark', 'iphone', 'macbook', 'android'
  
  const response = await fetch('https://api.screenshotly.app/screenshot', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      url,
      device: 'desktop',
      format: 'png',
      mockup: {
        type: mockupType,
        shadow: true,
        background: '#f5f5f5',
      },
    }),
  });

  return Buffer.from(await response.arrayBuffer());
}

Full-Page Capture

Capture entire scrollable pages:

async function captureFullPage(url) {
  const response = await fetch('https://api.screenshotly.app/screenshot', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      url,
      device: 'desktop',
      format: 'png',
      fullPage: true,
    }),
  });

  return Buffer.from(await response.arrayBuffer());
}

PDF Generation

Convert web pages to PDF documents:

async function generatePDF(url, options = {}) {
  const response = await fetch('https://api.screenshotly.app/screenshot', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      url,
      format: 'pdf',
      pdfOptions: {
        pageSize: options.pageSize || 'A4',
        printBackground: options.printBackground !== false,
        margin: options.margin || {
          top: '20mm',
          bottom: '20mm',
          left: '15mm',
          right: '15mm',
        },
      },
    }),
  });

  return Buffer.from(await response.arrayBuffer());
}

Production Patterns

Async Processing with Queues

For high-volume applications, use a queue to process screenshots asynchronously:

// queue.js
const Queue = require('bull');
const { captureScreenshot } = require('./screenshot');
const Redis = require('ioredis');

const screenshotQueue = new Queue('screenshots', {
  redis: {
    host: process.env.REDIS_HOST || 'localhost',
    port: process.env.REDIS_PORT || 6379,
  },
});

// Process screenshots
screenshotQueue.process(async (job) => {
  const { url, options, callbackUrl } = job.data;
  
  try {
    const screenshot = await captureScreenshot(url, options);
    
    // Store result (e.g., upload to S3)
    const resultUrl = await uploadToS3(screenshot, job.id);
    
    // Notify callback if provided
    if (callbackUrl) {
      await fetch(callbackUrl, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          jobId: job.id,
          status: 'completed',
          url: resultUrl,
        }),
      });
    }
    
    return { status: 'completed', url: resultUrl };
  } catch (error) {
    console.error(`Job ${job.id} failed:`, error.message);
    throw error;
  }
});

// API endpoint to queue screenshots
app.post('/api/screenshot/async', async (req, res) => {
  const { url, options, callbackUrl } = req.body;
  
  const job = await screenshotQueue.add({
    url,
    options,
    callbackUrl,
  });
  
  res.json({
    jobId: job.id,
    status: 'queued',
  });
});

// Check job status
app.get('/api/screenshot/status/:jobId', async (req, res) => {
  const job = await screenshotQueue.getJob(req.params.jobId);
  
  if (!job) {
    return res.status(404).json({ error: 'Job not found' });
  }
  
  const state = await job.getState();
  const result = job.returnvalue;
  
  res.json({
    jobId: job.id,
    state,
    result,
  });
});

Webhook Integration

Receive notifications when screenshots complete:

// webhook-handler.js
app.post('/api/webhook/screenshot', async (req, res) => {
  const { jobId, status, url, error } = req.body;
  
  // Verify webhook signature (implementation depends on your auth strategy)
  if (!verifyWebhookSignature(req)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }
  
  if (status === 'completed') {
    // Process successful screenshot
    console.log(`Screenshot ${jobId} completed: ${url}`);
    await updateDatabase(jobId, { status: 'completed', url });
  } else {
    // Handle failure
    console.error(`Screenshot ${jobId} failed: ${error}`);
    await updateDatabase(jobId, { status: 'failed', error });
  }
  
  res.json({ received: true });
});

Caching Layer

Reduce API calls and costs with caching:

// cache.js
const Redis = require('ioredis');
const crypto = require('crypto');

const redis = new Redis(process.env.REDIS_URL);
const CACHE_TTL = 3600; // 1 hour

function generateCacheKey(url, options) {
  const data = JSON.stringify({ url, ...options });
  return `screenshot:${crypto.createHash('md5').update(data).digest('hex')}`;
}

async function getCachedScreenshot(url, options) {
  const key = generateCacheKey(url, options);
  const cached = await redis.getBuffer(key);
  return cached;
}

async function cacheScreenshot(url, options, screenshot) {
  const key = generateCacheKey(url, options);
  await redis.setex(key, CACHE_TTL, screenshot);
}

async function captureWithCache(url, options = {}) {
  // Check cache first
  const cached = await getCachedScreenshot(url, options);
  if (cached) {
    console.log('Cache hit');
    return cached;
  }
  
  // Capture and cache
  console.log('Cache miss, capturing...');
  const screenshot = await captureScreenshot(url, options);
  await cacheScreenshot(url, options, screenshot);
  
  return screenshot;
}

Rate Limiting

Protect your API from abuse:

// rate-limit.js
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const Redis = require('ioredis');

const redis = new Redis(process.env.REDIS_URL);

const screenshotLimiter = rateLimit({
  store: new RedisStore({
    sendCommand: (...args) => redis.call(...args),
  }),
  windowMs: 60 * 1000, // 1 minute
  max: 60, // 60 requests per minute
  message: {
    error: 'Too many requests, please try again later',
  },
  keyGenerator: (req) => {
    // Rate limit by API key or IP
    return req.headers['x-api-key'] || req.ip;
  },
});

app.use('/api/screenshot', screenshotLimiter);

Error Handling

Robust error handling for production:

// error-handler.js
class ScreenshotError extends Error {
  constructor(message, statusCode, details) {
    super(message);
    this.statusCode = statusCode;
    this.details = details;
  }
}

async function captureWithErrorHandling(url, options = {}) {
  const maxRetries = options.retries || 3;
  let lastError;
  
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      const response = await fetch('https://api.screenshotly.app/screenshot', {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${API_KEY}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          url,
          device: options.device || 'desktop',
          format: options.format || 'png',
          ...options,
        }),
        signal: AbortSignal.timeout(30000), // 30 second timeout
      });

      if (response.ok) {
        return Buffer.from(await response.arrayBuffer());
      }

      const errorText = await response.text();
      
      // Don't retry client errors (4xx)
      if (response.status >= 400 && response.status < 500) {
        throw new ScreenshotError(
          `Client error: ${response.status}`,
          response.status,
          errorText
        );
      }
      
      // Retry server errors (5xx)
      lastError = new ScreenshotError(
        `Server error: ${response.status}`,
        response.status,
        errorText
      );
      
    } catch (error) {
      if (error instanceof ScreenshotError && error.statusCode < 500) {
        throw error;
      }
      
      lastError = error;
      
      if (attempt < maxRetries) {
        const delay = Math.pow(2, attempt) * 1000; // Exponential backoff
        console.log(`Retry ${attempt}/${maxRetries} in ${delay}ms...`);
        await new Promise(resolve => setTimeout(resolve, delay));
      }
    }
  }
  
  throw lastError;
}

Complete Application

Here's a production-ready Express application combining all patterns:

// app.js
require('dotenv').config();
const express = require('express');
const helmet = require('helmet');
const compression = require('compression');

const { captureWithCache } = require('./cache');
const { screenshotLimiter } = require('./rate-limit');
const { captureWithErrorHandling } = require('./error-handler');

const app = express();

// Middleware
app.use(helmet());
app.use(compression());
app.use(express.json());

// Health check
app.get('/health', (req, res) => {
  res.json({ status: 'healthy' });
});

// Screenshot endpoint with all features
app.post('/api/screenshot', screenshotLimiter, async (req, res) => {
  const {
    url,
    device = 'desktop',
    format = 'png',
    fullPage = false,
    cache = true,
    aiRemoval = false,
    mockup = null,
  } = req.body;

  if (!url) {
    return res.status(400).json({ error: 'URL is required' });
  }

  try {
    const options = {
      device,
      format,
      fullPage,
    };
    
    if (aiRemoval) {
      options.aiRemoval = {
        enabled: true,
        types: ['cookie-banner', 'chat-widget', 'popup'],
      };
    }
    
    if (mockup) {
      options.mockup = { type: mockup, shadow: true };
    }

    let screenshot;
    if (cache) {
      screenshot = await captureWithCache(url, options);
    } else {
      screenshot = await captureWithErrorHandling(url, options);
    }

    const contentType = format === 'jpeg' ? 'image/jpeg' 
                     : format === 'pdf' ? 'application/pdf' 
                     : 'image/png';
    
    res.setHeader('Content-Type', contentType);
    res.setHeader('Cache-Control', 'public, max-age=3600');
    res.send(screenshot);
    
  } catch (error) {
    console.error('Screenshot error:', error);
    
    const statusCode = error.statusCode || 500;
    res.status(statusCode).json({
      error: error.message,
      details: error.details,
    });
  }
});

// Error handler
app.use((err, req, res, next) => {
  console.error('Unhandled error:', err);
  res.status(500).json({ error: 'Internal server error' });
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Screenshot API running on port ${PORT}`);
});

Deployment Considerations

Environment Variables

# Required
SCREENSHOTLY_API_KEY=your_api_key

# Optional
PORT=3000
REDIS_URL=redis://localhost:6379
NODE_ENV=production

Docker Deployment

FROM node:20-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production

COPY . .

EXPOSE 3000

CMD ["node", "app.js"]

Health Monitoring

Add metrics for production monitoring:

const promClient = require('prom-client');

// Metrics
const screenshotCounter = new promClient.Counter({
  name: 'screenshots_total',
  help: 'Total screenshots captured',
  labelNames: ['status', 'device'],
});

const screenshotDuration = new promClient.Histogram({
  name: 'screenshot_duration_seconds',
  help: 'Screenshot capture duration',
  buckets: [0.5, 1, 2, 5, 10, 30],
});

// Metrics endpoint
app.get('/metrics', async (req, res) => {
  res.set('Content-Type', promClient.register.contentType);
  res.send(await promClient.register.metrics());
});

Conclusion

Building screenshot capabilities into Node.js applications is straightforward with a REST API. We've covered:

  1. Basic integration - Simple function to capture screenshots
  2. Express endpoints - RESTful API for your application
  3. Advanced features - AI removal, mockups, full-page capture
  4. Production patterns - Caching, queuing, rate limiting, error handling
  5. Deployment - Docker, environment configuration, monitoring

Start with the basic implementation and add complexity as your needs grow.


Ready to add screenshots to your Node.js app?

Get your free API key → - 100 free screenshots to get started.

See the Node.js SDK documentation → for more examples.

nodejs
tutorial
api
express
automation

About the Author

Asad Ali

Asad Ali

Full-Stack Developer and Founder of ZTabs with 8+ years of experience building scalable web applications and APIs. Specializes in performance optimization, SaaS development, and modern web technologies.

Credentials: Founder & CEO at ZTabs, Full-Stack Developer, Expert in Next.js, React, Node.js, and API optimization

Frequently Asked Questions

Should I use node-fetch or native fetch?

On Node 18+, use native fetch (or undici directly). node-fetch is deprecated for new projects and adds dependency weight for no benefit. The native API is fully compatible with the browser fetch interface, which makes the same client code work across environments.

How do I stream large captures to S3 without buffering in memory?

Pipe the fetch response body directly into the S3 upload stream: pass response.body (a ReadableStream) to the SDK's upload function. This avoids loading multi-MB PDFs into RAM, which matters once you run 20+ concurrent captures per worker.

What should graceful shutdown look like for a capture worker?

On SIGTERM, stop consuming new jobs, finish in-flight captures (typically under 5 seconds), flush any buffered metrics, and exit 0. A shutdown handler missing any of those steps produces lost captures and orphaned S3 multipart uploads.

Ready to capture your first screenshot?

Get started with 100 free screenshots. No credit card required.

Related Articles