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

🧪 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:



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.