Skip to content
hungnguyen.tech
Technical · · 4 min read

Server-side share images with NestJS, BullMQ and GCS

How I render dynamic social share images in a background worker — and the duplicate-render race condition that only showed up under real load.

The short version: render share images in a background worker, cache them in object storage keyed by a content hash, and make the job idempotent — because the moment two requests ask for the same not-yet-rendered image at once, a naïve queue will render it twice and can serve a half-written file. Here’s the design, and the race that taught me the last part.

Why render share images server-side at all?

Because you cannot trust the client to do it. Social crawlers (the thing that unfurls a link into a card) don’t run your JavaScript, they don’t wait, and they hammer the URL the instant a link is posted. If the image isn’t a plain, already-rendered file sitting at a stable URL when the crawler arrives, you get a blank card. So the image has to exist server-side, be cheap to serve, and be produced deterministically from the content it represents.

The pipeline I settled on:

  1. A request for /share/:id.png computes a content hash of everything that affects the pixels (title, author, theme, template version).
  2. If an object already exists in GCS at that hash, redirect to it. Done — no rendering.
  3. If not, enqueue a render job and return a placeholder (or block briefly) while a worker produces it.
  4. The worker renders with headless Chromium, writes to GCS, and future requests hit step 2.

Why a queue instead of rendering inline?

Rendering with headless Chromium is heavy — hundreds of milliseconds to seconds, and memory-hungry. Doing it inline on the request thread means a burst of link-shares can exhaust your web dyno’s memory and take down unrelated traffic. Pushing renders onto BullMQ (Redis-backed) gives you a concurrency limit you actually control: N workers, N renders at a time, everything else waits in line instead of falling over.

// The queue is the backpressure valve. Concurrency is a knob, not a hope.
new Worker('share-images', renderShareImage, {
  connection,
  concurrency: 4, // TODO(hung): tune to worker memory headroom
});

The race condition that only appeared under load

In testing, everything was clean. In the real world, a popular link gets shared many times in the same second — and every one of those requests found no cached object yet and enqueued its own render job for the identical image. Two problems fell out of that:

  • Wasted work: the same expensive image rendered four, five, ten times concurrently.
  • The real bug — torn reads: a request would occasionally redirect to a GCS object that another worker was still in the middle of writing, serving a truncated PNG. The card showed up broken maybe one time in a few hundred. Intermittent, load-dependent, and invisible in any single-request test.

The tell was in the logs once I looked at them the right way: multiple render.start lines for one content hash within the same millisecond band, and the broken-image reports clustered exactly on high-share-rate content. It wasn’t a rendering bug at all — it was a concurrency and publish-atomicity bug.

How I fixed it

Two changes, both about making the operation idempotent and its output atomic:

1. Deduplicate the job. BullMQ lets you set a job ID. Using the content hash as the job ID means concurrent enqueues for the same image collapse into a single job — the queue itself becomes the dedup lock.

await queue.add('render', { id, hash }, {
  jobId: hash,              // identical hash => one job, not ten
  removeOnComplete: true,
});

2. Make the write atomic. Never let a reader see a partial object. The worker renders to a temporary key, then finalizes to the real key only once the bytes are fully written — so an object at the canonical hash path is, by construction, complete.

const tmp = `tmp/${hash}.${crypto.randomUUID()}.png`;
await bucket.file(tmp).save(png, { resumable: false });
await bucket.file(tmp).move(`share/${hash}.png`); // atomic publish

With those two in place, the torn-read reports went to zero and redundant renders collapsed to one per unique image.

The lesson worth keeping

The bug wasn’t in the rendering code, which is where I first looked. It was in the coordination between requests that each looked correct in isolation. Anytime a system does “check if it exists, and if not, create it,” you have to assume two callers will run that check at the same time — and design the create step so that racing is safe and the published result is never half-formed. The queue-as-dedup-lock plus write-to-temp-then-move pattern is the smallest thing that made both true here.

TODO(hung): add the p95 render time and the observed dedup ratio (jobs enqueued vs. jobs actually rendered) once you re-run the load test.