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, 2025β€’By 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.

Comments