Skip to main content

Two Meanings of "Worker"

"Worker" names three unrelated things. They share a word and nothing else, so it is worth separating them before anything else.

Web Worker A second JavaScript thread in the browser. Runs computation so the UI keeps responding.
Queue worker A consumer that pulls units of work off a queue. Lives on the server.
Service worker A network proxy for a PWA. Unrelated to both of the above — see the PWA showcase.

The tell: ask what it consumes. A queue worker consumes from a queue, a Web Worker consumes messages you send it, a service worker consumes fetch events. A job consumes nothing — it wakes on a timer and runs.

Worker A — Browser Thread

Both buttons below run the exact same function — decode the image, downscale it, then walk every pixel to build a colour histogram. The only variable is which thread it runs on. Watch the dial, not the palette.

This browser lacks OffscreenCanvas or createImageBitmap, so the demo cannot run here.

The worker path transfers the ArrayBuffer rather than copying it: postMessage(data, [buffer]) moves ownership and neuters the sender's copy. On a large photo that is the difference between a copy and a pointer hand-off — and it is the most-missed part of the API.

Worker B — Queue Worker

Dormant

This deployment is serverless (vercel), so no process survives the response and the worker cannot be resident. A daily cron drains the queue instead. The dormancy is the lesson, not a defect — it is why the whole job system is push-triggered.

The notification outbox is a real queue table, and this is its consumer. Work is taken with one atomic statement: rows are locked, marked, and returned together, so two workers can never take the same row.

outbox.ts — claimDeliveries()
UPDATE notifications.notification_deliveries AS d
SET status = 'processing',
    attempts = d.attempts + 1,
    attempted_at = now()
FROM (
    SELECT id
    FROM notifications.notification_deliveries
    WHERE status = 'pending'
      AND next_attempt_at <= now()
    ORDER BY next_attempt_at, created_at
    LIMIT $1
    FOR UPDATE SKIP LOCKED
) AS c
WHERE d.id = c.id
RETURNING d.id, d.channel, d.attempts;
Batch size 25
Max attempts 5
Retry backoff 30s → 2m → 8m → 32m
Claim lease 5m

Live queue depth

0 Pending
0 Processing
1 Sent
0 Failed
0 Dead

The attempt counter doubles as a fence token. If a worker dies mid-send, the reaper requeues its row after the lease expires — and because a re-claim bumps the counter, the dead worker's late write no longer matches and is discarded instead of corrupting the row.

Not Workers: Jobs

This project's background work is made of jobs, not workers. The distinction is not pedantry — it decides whether you need a queue at all.

Job A unit of work. Something wakes it and tells it what to run. Costs nothing when idle because it does not exist when idle.
Worker A process that is already running and asks the queue what is due. Costs money while idle, and gives you retries and fast pickup in exchange.

Everything scheduled here is pushed rather than pulled: a cron, an interval, or an admin click names the work directly. See the jobs showcase.

Think this pattern could be better? Tell us how.

Leave feedback