π§ͺ 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.