Building a Lightweight Headless Rendering Service on GCP e2-micro for Safety Link Preview

How qz-l.com built an ultra-efficient Playwright-based renderer on a tiny GCP e2-micro VM to safely extract metadata, OG tags, and page text for Safety Link Preview.

November 26, 2025By qz-l team

🧪 Deep Dive: How We Built a Lightweight Headless Rendering Service on a GCP e2-micro (2 vCPU / 1GB RAM)

At QZ-L.com, safety matters.
To power our Safety Link Preview + AI Analysis, we needed a way to:

  • visit any webpage
  • extract its title, metadata, OG tags, favicon
  • capture the inner text for classification
  • detect malicious patterns early
  • and do all of this safely, cheaply, and reliably

Most developers would use a hosted service like browserless.com or a full server cluster.

We wanted something lighter, cheaper, and fully under our control.

So we built our own rendering service — on the smallest Google Cloud instance available.


⚙️ Our Infrastructure

Machine

GCP e2-micro

  • 2 vCPU
  • 1 GB RAM
  • ~$0.01/hour
  • shared CPU, minimal memory
  • perfect for low-cost background tasks

Running headless Chromium on such a small machine is not trivial, but with the right engineering constraints, it's absolutely possible.

This article walks you through exactly how we did it.


🚧 Challenge 1: Headless Browsers Consume Huge RAM

A typical Chromium instance often consumes:

  • 300–700MB at startup
    • more memory per open page
    • more memory per network request

On a 1GB machine, this is catastrophic.

❌ Launching a new browser per request

→ instant crash
→ OOM kill
→ instance becomes unresponsive

❌ Opening multiple pages in parallel

→ memory spike
→ VM freeze
→ chromium segfault

So we needed a more disciplined approach.


✅ Solution: One Shared Browser, One Shared Queue

To keep memory stable and predictable, we decided:

1. Launch ONE Chromium instance at startup

No relaunching, no multiple browsers.

browser = await chromium.launch({
  headless: true,
  args: ["--no-sandbox", "--disable-gpu"],
});

2. Do NOT allow parallel rendering

Parallelism = memory explosion.
Instead, we serialize all requests through a global Promise queue.

let queue = Promise.resolve();

Every incoming request is chained behind the previous one.

3. One page at a time

Each request:

  • opens a new page
  • loads the page
  • extracts metadata
  • closes the page
  • returns the result

This alone saves hundreds of MB.


🚧 Challenge 2: Big Sites Never Reach networkidle

Sites like:

  • YouTube
  • Amazon
  • e-commerce platforms
  • newspaper sites
  • anything with infinite scripts

…never truly reach "networkidle".

Waiting for "networkidle" on a low-memory instance is:

  • slow
  • risky
  • may never resolve
  • wastes CPU time
  • crashes Chromium

✅ Solution: Use domcontentloaded Only

We load until the DOM is ready — not until every ad, stream, beacon, and analytics script finishes.

await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });

This reliably gives us:

  • <title>
  • <meta> tags
  • OG metadata
  • favicon
  • inner text
  • and a stable DOM tree

Perfect for Safety Link Preview.


🚧 Challenge 3: Chromium Might Crash Under Load

Tiny VMs sometimes kill Chromium due to:

  • CPU spikes
  • memory pressure
  • internal Chromium errors
  • network timeouts

A dead browser = dead service.


✅ Solution: Auto-Heal Browser

We built a wrapper that automatically relaunches Chromium if disconnected.

if (!browser || !browser.isConnected()) {
  browser = await chromium.launch({
    headless: true,
    args: ["--no-sandbox", "--disable-gpu"],
  });
}

Our service self-recovers without manual intervention.


🚧 Challenge 4: Keeping Memory Low per Request

Every page instance adds memory usage.
Leaving pages open is deadly.
Opening too many pages causes hard crashes.


✅ Solution: Always Close Pages Immediately

Each render is wrapped in:

try {
  // load + extract
} finally {
  await page.close();
}

We never keep pages alive between requests.


📦 Full Source Code (Running on qz-l.com Render Node)

// the headless browser renderer service running with Playwright and Express on GCP ec2-macro
import express from "express";
import { chromium } from "playwright";

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

let browser;
let queue = Promise.resolve(); // initialize a serial queue

// Launch browser once
(async () => {
  try {
    console.log("Launching Chromium...");
    browser = await chromium.launch({
      headless: true,
      args: ["--no-sandbox", "--disable-gpu"],
    });
    console.log("Chromium launched");
  } catch (err) {
    console.error("Failed to launch Chromium:", err);
  }
})();

async function ensureBrowser() {
  if (!browser || !browser.isConnected()) {
    console.log("Restarting Chromium...");
    browser = await chromium.launch({
      headless: true,
      args: ["--no-sandbox", "--disable-gpu"],
    });
  }
}

// Function to render a page (runs in queue)
async function renderPage(url) {
  await ensureBrowser();

  const page = await browser.newPage();
  try {
    await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });

    const title = await page.title().catch(() => "");
    const text = await page.innerText("body").catch(() => "");
    const desc = await page.$eval('meta[name="description"]', el => el.getAttribute("content")).catch(() => "");
    const ogTitle = await page.$eval('meta[property="og:title"]', el => el.getAttribute("content")).catch(() => "");
    const ogDesc = await page.$eval('meta[property="og:description"]', el => el.getAttribute("content")).catch(() => "");
    const ogImage = await page.$eval('meta[property="og:image"]', el => el.getAttribute("content")).catch(() => "");
    const favicon = await page.$eval('link[rel="icon"]', el => el.getAttribute("href")).catch(() => "");

    return {
      url,
      metadata: { title, desc, ogTitle, ogDesc, image: ogImage, favicon },
      text,
    };
  } finally {
    await page.close();
  }
}

app.post("/render", (req, res) => {
  const { url } = req.body;
  if (!url) return res.status(400).json({ error: "Missing url" });

  // Add this request to the queue
  queue = queue
    .then(() => renderPage(url))
    .then(result => res.json(result))
    .catch(err => {
      console.error("Renderer error:", err);
      res.status(500).json({ error: err.message });
    });
});

const PORT = 8911;
app.listen(PORT, () => console.log(`Renderer running on port ${PORT}`));

🎉 Conclusion

This lightweight renderer powers the Safety Link Preview on qz-l.com — allowing us to:

  • fetch metadata
  • render pages safely
  • extract OG info
  • analyze content
  • detect risks
  • protect users

All on a 1GB GCP instance.

It’s fast, stable, cheap, and purpose-built for safety.

If you'd like to see code snippets, architecture diagrams, or a Docker version, let us know — we’re happy to share more updates as we continue to improve the system.

Related Posts

How qz-l.com Built Safety Link Preview + AI Summary

A deep technical dive into how qz-l.com analyzes webpage content using a custom headless renderer and AI to provide safety scores, summaries, categories, and risk notes.

Introducing Safety Link Preview Inside the qz-l Chatbot

Our chatbot can now automatically detect links, analyze their safety, and show rich AI-powered safety previews — built directly into the chat experience.

Introducing Safety Link Preview - Know Before You Click

Preview your links safely with our new Safety Link feature. See link content and risks before visiting. Safety first!

Building a Lightweight Headless Rendering Service on GCP e2-micro for Safety Link Preview | qz-l