Most pages we need to read are static enough that a plain HTTP fetch plus an HTML parse does the job. But some are single-page apps that render their real content in JavaScript — for those, you need an actual browser. Browserless (headless Chrome as a service) is the usual answer, either as a paid API or a container you run yourself.
Both have the same problem for a bursty, low-average workload: you pay for capacity you mostly don't use. The SaaS bills per session; a self-hosted droplet bills 24/7 whether or not anything's rendering. Our actual usage is spiky — long quiet stretches, occasional backlogs. So we built a third option: a Browserless droplet that only exists when there's work.
TL;DR
- Bake a reusable snapshot of a fully-configured Browserless droplet (in CI, not on your laptop).
- When a backlog appears, boot an ephemeral droplet from that snapshot via cloud-init.
- Point the app at it, drain the queue, then reap the droplet.
- Idle cost: $0. You pay only for the minutes you actually render.
Why not just leave a small droplet running?
We tried. A s-1vcpu-* box is cheap, but "cheap 24/7" for something used a few minutes a day is still money for nothing — and headless Chrome is memory-hungry enough that the small box thrashed under any real burst. Sizing for the peak and running it constantly is the worst of both worlds.
The insight: our workload doesn't need a server, it needs a worker that appears on demand. DigitalOcean droplets boot in well under a minute from a snapshot, and their API is scriptable. That's enough to make "spin up, drain, destroy" cheaper and faster than babysitting an always-on box.
Step 1: Bake the snapshot (in CI, not on your laptop)
The slow, fiddly part — install Docker, pull the Browserless image, configure it, harden it — you do once, and capture the result as a snapshot. We do this on a GitHub runner rather than a laptop, because the runner has stable networking and no "works on my machine" surprises:
temp droplet ──▶ run setup script ──▶ snapshot ──▶ destroy temp droplet
The workflow prints the new snapshot ID, which we store as a repo variable (DO_BROWSERLESS_SNAPSHOT_ID). Re-baking on an image or config change is a one-click workflow dispatch. The bake droplet is torn down at the end, so the baking itself costs pennies.
Doing this in CI matters: the snapshot is now a build artifact with a provenance trail, not a hand-crafted pet nobody remembers how to reproduce.
Step 2: Boot on demand from the snapshot
An orchestrator in the app watches the backlog. When there's enough queued work to justify a worker, it asks the DO API to create a droplet from the snapshot, tagged so we can find and clean up strays later:
doctl compute droplet create jo4-browserless-ephemeral \
--image "$SNAPSHOT_ID" \
--size s-2vcpu-4gb \
--region nyc3 \
--tag-name jo4-browserless \
--user-data-file cloud-init.yaml \
--wait
cloud-init.yaml handles the last-mile: start the Browserless container, wait for its health endpoint, and register the endpoint so the app knows where to send work. Because the heavy lifting is already in the snapshot, first-render readiness is a boot plus a container start — not a full provision.
Step 3: Route work, then reap
Once the endpoint is live, the app flips from "Jsoup-only" extraction to "use the browser for the JS-heavy ones," drains the backlog, and then a scheduler reaps the droplet. Two safety nets keep a stray from silently running up a bill:
- A max-age reaper destroys any
jo4-browserless-tagged droplet older than N minutes — so even if the app crashes mid-drain, the worker doesn't outlive its usefulness. - If the snapshot ID, DO token, or endpoint are unset, the whole thing no-ops and extraction quietly falls back to the plain HTTP path. Missing config degrades gracefully instead of erroring.
That reaper is the single most important piece. "Spin up on demand" is easy; the failure mode that actually costs money is "forgot to spin down." Make destruction a scheduled invariant, not a hopeful last line in a happy-path script.
The cost shape
The economics flip completely:
| Model | Idle cost | Burst behavior |
|---|---|---|
| Browserless SaaS | $0 | pay per session, adds up fast |
| Always-on droplet | full monthly rate | fixed capacity, thrashes on spikes |
| Ephemeral droplet | $0 | scale the size per-burst, pay per minute |
For a spiky, low-average workload, ephemeral wins on both axes: nothing when quiet, right-sized when busy.
Gotchas worth knowing
- Boot-to-ready isn't instant. Batch your work so a worker's lifetime amortizes its ~1-minute startup. Booting a droplet to render one page is a bad trade.
- Tag everything and reap by tag. Your cleanup must be able to find droplets the app forgot about. Tags are how.
- Re-bake the snapshot on base-image updates, or you're booting last quarter's Chrome with last quarter's CVEs.
- Make missing config a no-op, not a crash. An ephemeral subsystem that fails closed (falls back to the simpler path) is one you can ship without fear.
Lessons learned
Some workloads don't want a server — they want a worker that materializes on demand and disappears. Snapshots plus a scriptable cloud API make "boot, drain, destroy" a legitimate architecture, not a hack. The only hard part is discipline about destroying things, and that you solve with a dumb, reliable, scheduled reaper.
How do you handle bursty, heavy workloads? Always-on, serverless, or spin-up-and-reap like this? Curious where others draw the line.
Thanks to the folks building jo4.io — a URL shortener with analytics — for letting us over-engineer the cheap path.