Running Production Puppeteer on a Budget VPS

You know that feeling when your little side project starts getting real traffic, and suddenly your $5 VPS is gasping for air like a marathon runner in a desert? That was me two weeks ago. I had a Puppeteer-based service—a simple headless browser that screenshots web pages for users—and it was crashing every 20 minutes. The culprit? Memory. Puppeteer is a hungry beast, and on a 512MB RAM VPS, it’s like trying to feed a Great Dane with a teaspoon. But here’s the thing: you don’t need a $100/month server to run production Puppeteer. You just need to be smart about memory limits, crash recovery, and queuing. Let me show you how I tamed the beast on a budget.

The Problem: Why Puppeteer Eats RAM Like Candy

Puppeteer launches a full Chromium browser instance for every task. That means each page load, script execution, and screenshot can balloon memory usage to 200-300MB or more. On a 512MB VPS, that leaves almost no room for the OS, Node.js, or any other processes. And if you’re running multiple Puppeteer instances in parallel? Forget it. You’ll hit swap, then OOM (out-of-memory) killer, and your process dies silently. Your users get 500 errors, and you get angry emails.

I learned this the hard way. My app was a simple screenshot API: user submits a URL, Puppeteer opens it, waits for the page to load, takes a screenshot, and returns it. Under light load, it worked fine. But as soon as two requests hit simultaneously, the VPS would freeze, then crash. I checked htop and saw memory usage spike to 490MB, then the kernel killed the Node process. No graceful shutdown, no error logs—just a dead app.

The root cause was obvious: I was running Puppeteer without any guardrails. I needed to limit memory, handle crashes gracefully, and ensure only one task ran at a time. Here’s exactly how I fixed it.

The Solution: One-at-a-Time Queuing, Memory Limits, and Crash Recovery

I rebuilt the system around three principles: single-threaded execution, hard memory caps, and automatic restart on failure. Let me walk you through the code and decisions.

1. One-at-a-Time Queuing with Bull

Instead of handling requests immediately, I push them into a queue. I used Bull, a Redis-backed job queue, because it’s lightweight and handles retries out of the box. Here’s the setup:

const Queue = require('bull');
const screenshotQueue = new Queue('screenshots', {
  redis: { host: '127.0.0.1', port: 6379 },
  limiter: { max: 1, duration: 1000 } // only 1 job per second
});

// Process one job at a time
screenshotQueue.process(1, async (job) => {
  const browser = await puppeteer.launch({
    headless: true,
    args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage']
  });
  try {
    const page = await browser.newPage();
    await page.goto(job.data.url, { waitUntil: 'networkidle0', timeout: 30000 });
    const screenshot = await page.screenshot({ type: 'png' });
    return screenshot;
  } finally {
    await browser.close();
  }
});

The key is limiter: { max: 1, duration: 1000 }—this ensures only one job runs per second, effectively serializing all requests. No two Puppeteer instances ever overlap. The queue also stores failed jobs for retry, which is critical for crash recovery.

2. Memory Limits: The Chromium Args That Save You

Chromium has built-in flags to restrict memory. I added these to the args array:

const browser = await puppeteer.launch({
  headless: true,
  args: [
    '--no-sandbox',
    '--disable-setuid-sandbox',
    '--disable-dev-shm-usage',
    '--max-old-space-size=256', // limit Node.js heap to 256MB
    '--js-flags="--max-old-space-size=256"',
    '--disable-gpu',
    '--single-process' // keep Chromium in one process, easier to monitor
  ]
});

The --max-old-space-size flag is a V8 option that caps the JavaScript heap. I set it to 256MB, which is generous for most page screenshots. The --single-process flag reduces overhead by running Chromium in a single process instead of spawning multiple. Combined, these keep total memory usage under 300MB per job.

But even with limits, crashes happen. So I added a watchdog.

3. Crash Recovery: Auto-Restart with PM2 and Health Checks

I run the Node process under PM2 with a memory limit and auto-restart:

pm2 start app.js --max-memory-restart 400M --restart-delay 5000

This tells PM2 to restart the app if it exceeds 400MB RAM. The --restart-delay 5000 gives the system time to recover. But I also added a health check endpoint that Bull uses to retry failed jobs:

app.get('/health', async (req, res) => {
  const isReady = await screenshotQueue.isReady();
  res.status(isReady ? 200 : 503).json({ status: isReady ? 'ok' : 'unavailable' });
});

// In Bull queue, retry failed jobs up to 3 times
screenshotQueue.on('failed', (job, err) => {
  console.error(`Job ${job.id} failed: ${err.message}`);
  if (job.attemptsMade < 3) {
    job.retry();
  }
});

Now, if Puppeteer crashes mid-job, the queue marks the job as failed, logs the error, and retries it after a delay. Meanwhile, PM2 restarts the Node process, and the health check ensures Redis is still connected. It’s not perfect—some jobs still fail permanently if the URL is bad—but it’s resilient.

Key Takeaways: What I Learned on a Budget

First, serialize everything. On a low-memory VPS, parallelism is your enemy. Queuing with Bull turns concurrent requests into a gentle stream, and the user experience is still fine if your average job takes 2-5 seconds. Second, set hard limits. Chromium’s flags are your best friend—use them. Without --max-old-space-size, my process would balloon to 500MB. With it, it stays under 300MB. Third, plan for failure. PM2 auto-restart and Bull retries saved my bacon multiple times. The app now runs for weeks without manual intervention.

One honest reflection: this setup won’t scale to hundreds of requests per second. But for a side project doing 50-100 screenshots a day, it’s rock solid. Also, Redis adds some overhead—if you’re really tight on RAM, consider using an in-memory queue like async.queue from the async library. But Bull’s persistence and retry logic are worth the extra 10MB.

Another lesson: monitor your swap usage. On a 512MB VPS, when memory hits the limit, swap kicks in and performance tanks. I added a cron job that warns me if swap usage exceeds 100MB:

#!/bin/bash
SWAP_USED=$(free | grep Swap | awk '{print $3}')
if [ $SWAP_USED -gt 100000 ]; then
  curl -X POST -H "Content-Type: application/json" \
    -d '{"text":"Swap usage high: '$SWAP_USED' kB"}' \
    https://hooks.slack.com/services/YOUR_WEBHOOK
fi

This alert saved me from a silent meltdown last week when a user submitted a 50MB page.

Closing Thought

Running Puppeteer on a budget VPS feels like driving a sports car on a dirt road—it’s possible, but you need to slow down and reinforce the suspension. The tools are there: Bull for queuing, Chromium flags for memory limits, PM2 for recovery. You just have to wire them together with a bit of patience and a lot of error handling. My app is now humming along on a $5 DigitalOcean droplet, serving screenshots without a hiccup. And honestly? That feels better than throwing money at a bigger server. Sometimes the best optimizations aren’t about buying more—they’re about using less, smarter.

Got a similar setup? I’d love to hear your war stories. Drop a comment or tweet at me. Happy hacking!