Baggrundsjob uden drama (cron, queue eller bare requesten selv?)
Har du nogensinde tilføjet “lige” en ekstra ting til et endpoint og pludselig sidder brugeren og glor på en spinner i 8 sekunder?
De tre måder dine baggrundsjobs kan leve på
Når din webapp vokser, ender du ret hurtigt med ting, der ikke passer godt ind i en almindelig request: e-mails, PDF’er, imports, synk til andre systemer osv. Hvis du bare læsser det hele ind i din route handler, får du:
- time outs
- dublette operationer når klienten prøver igen
- og en server, der føles tung og uforudsigelig
Der er groft sagt tre mønstre at vælge mellem:
1. Synkront i requesten
Alt sker inde i HTTP-requesten. Klienten venter, til du er helt færdig.
app.post("/signup", async (req, res) => {
const user = await createUser(req.body)
await sendWelcomeEmail(user.email) // alt sker her
res.status(201).json({ id: user.id })
})
Fordele:
- Nem at forstå og debugge
- Ingen ekstra komponenter
Ulemper:
- Lang svartid
- Hvis requesten fejler halvvejs, aner du ikke hvad der blev kørt
- Ingen naturlige retries på dele af jobbet
2. Queue + worker
Her lægger du et job i en queue (kø), og en separat worker-proces plukker job op og udfører dem asynkront. Requesten svarer hurtigt tilbage: “Job modtaget”.
// i request handler
await jobsQueue.add("send-welcome-email", { userId: user.id })
res.status(202).json({ accepted: true })
// i worker-processen
jobsQueue.process("send-welcome-email", async (job) => {
const user = await db.users.findById(job.data.userId)
await sendWelcomeEmail(user.email)
})
Fordele:
- Job kan retrys uafhængigt af HTTP
- Du kan skalere workers op og ned
- God til tunge eller mange opgaver
Ulemper:
- Mere infrastruktur og mere at holde øje med
- Du skal selv tænke over idempotens og dubletter
3. Cron / scheduler
Cron (eller en hosted scheduler) kører et script på faste tidspunkter: hvert minut, hver time, hver nat osv. God til batch-opgaver.
# Eksempel på cron
0 * * * * /usr/bin/node /app/scripts/send-daily-report.js
Fordele:
- Perfekt til “scan alt og gør noget” opgaver
- Uafhængig af individuelle requests
Ulemper:
- Ingen instant reaktion på brugerens handling
- Kan blive tungt hvis scriptet skal igennem meget data ofte
Hvis du har brug for at få overblikket over, hvad der ellers hører under klassisk backend til web, så er baggrundsjob bare én af byggeklodserne.
Hvad skal du vælge? Fire spørgsmål der styrer beslutningen
I stedet for at starte med “vi skal have en queue”, giver det mere mening at starte med et par simple kriterier.
1. Latency: hvor hurtigt skal brugeren have svar?
Hvis brugeren skal have et svar på under 500 ms, kan du ikke køre et 5-sekunders PDF-job i samme request. Så simpelt er det.
- Mindre end 1 sekund: overvej queue for alt der går tæt på grænsen
- Op til 5-10 sekunder: kan måske leve i requesten, hvis det ikke er kritisk
- Mere end 10 sekunder: hører næsten altid hjemme i queue eller cron
2. Reliability: hvad sker der hvis det fejler?
Nogle ting skal bare lykkes. Andre må gerne fejle og prøves igen senere.
- Kritisk side-effekt (fakturering, ændring i lager, vigtige e-mails): du vil typisk have en queue med retries og evt. dead-letter queue
- Nice to have (logning, analytics): kan også leve i requesten eller i en “fire and forget” queue uden for meget styr på retries
3. Idempotens: kan jobbet tåle dubletter?
Idempotent = du kan køre den samme operation flere gange med samme input uden at lave rod.
- “Sæt feltet til 42” er idempotent
- “Læg 42 til feltet” er ikke
Hvis jobbet ikke er idempotent (penge, point, saldo osv.), skal du være ekstra forsigtig med både queue og cron, for de vil normalt prøve igen ved fejl.
4. Cost: hvad må det koste i drift?
En job queue betyder ofte:
- endnu en service (Redis, SQS, RabbitMQ)
- ekstra worker-processer
Hvis du har en lille hobby-side på shared hosting, er cron på serveren eller en hosted scheduler plus et simpelt script nogle gange rigeligt. Det behøver ikke ligne et enterprise-setup bare fordi du har ét baggrundsjob.
Tre konkrete eksempler: e-mail, PDF, CSV-import
Eksempel 1: Sende e-mail
Sign up formularen vil sende en velkomstmail. Hvad gør du?
Synkront i requesten kan være fint til:
- lav trafik
- uformelle projekter, hvor “lidt langsomt” er ok
Queue + worker giver mening når:
- du har flere typer e-mails
- ekstern e-mail-udbyder nogle gange fejler eller er langsom
- du vil kunne retrie specifikke e-mails uden at brugere skal lave et nyt request
Her er et lille mønster, jeg bruger ofte:
// i signup-requesten
await jobsQueue.add("send-email", {
type: "welcome",
userId: user.id
})
// i worker
jobsQueue.process("send-email", async (job) => {
const user = await db.users.findById(job.data.userId)
if (!user) return
if (job.data.type === "welcome") {
await sendWelcomeEmail(user.email)
}
})
Her er e-mailen idempotent nok: hvis den sendes to gange, er det irriterende, men ikke katastrofalt.
Eksempel 2: Generere PDF
En bruger klikker “Download faktura som PDF”.
Synkront i requesten kan være ok hvis:
- PDF’en genereres på under 1-2 sekunder
- der ikke er hundredvis af samtidige kald
Queue er oplagt når:
- PDF-generationen tager lang tid
- du vil cache eller gemme resultatet til senere køb
- mange brugere kan trigge samme PDF
Et lille asynkront flow kunne være:
// 1) Bruger starter generering
app.post("/invoice/:id/pdf", async (req, res) => {
const invoiceId = req.params.id
const jobId = await jobsQueue.add("generate-pdf", { invoiceId })
res.status(202).json({ jobId })
})
// 2) Klient poller status
app.get("/jobs/:jobId", async (req, res) => {
const job = await jobsQueue.getJob(req.params.jobId)
res.json({ status: job.status, resultUrl: job.resultUrl })
})
Så kan klienten vise en spinner og hente PDF’en når den er klar, uden at HTTP-requestet står åbent hele tiden.
Eksempel 3: Importere CSV
Bruger uploader en stor CSV-fil med produkter.
- Den type opgave hører næsten altid hjemme i en queue eller et cron-lignende batch script
- Det kan tage minutter
- Du vil kunne genstarte importen, skippe enkelte rækker, logge fejl osv.
Her kan cron også være interessant: du lægger filen i en mappe/tabeller, og et cron-job scanner “nye” importer hvert minut og starter behandling i batches.
Minimum viable job queue: hvad er “nok” i små apps?
Du behøver ikke whole-kirkeorgel-setup for at få en reel baggrundsjob queue. Den simple version skal kunne:
- gemme jobs et sted der overlever et crash (fx Redis eller database)
- have en worker-proces der poller og kører jobs
- have retries med backoff
- logge fejl så du kan finde dem igen
Et simpelt Node-setup i pseudo
// queue.js
import { createClient } from "redis"
const redis = createClient()
export async function enqueue(queueName, payload) {
await redis.lPush(queueName, JSON.stringify(payload))
}
export async function dequeue(queueName) {
const data = await redis.rPop(queueName)
return data ? JSON.parse(data) : null
}
// worker.js
import { dequeue } from "./queue.js"
async function runWorker() {
while (true) {
const job = await dequeue("email-jobs")
if (!job) {
await sleep(1000)
continue
}
try {
await handleJob(job)
} catch (err) {
console.error("Job failed", job.id, err)
// todo: retry eller læg i dead-letter
}
}
}
Det er ikke produktionsklart som det står, men det er samme idé du finder i mange “rigtige” job queue libraries til Node, Python, Ruby osv.
Hvis du synes den type kode er hyggelig at rode med, så er kategorier som projektstruktur og arkitektur og deployment og drift ret meget samme nørde-energi, bare i lidt større skala.
Dedupe og idempotens: sådan undgår du kaos ved retries
Job queues og cron-jobs vil før eller siden køre det samme job mere end én gang. Netværk fejler, processen crasher midt i jobbet, en admin klikker “prøv igen”.
Gør selve jobbet idempotent, hvis du kan
Eksempel: “tilføj 100 kr. til saldo” vs “sæt saldo til 500 kr.”.
-- ikke idempotent
UPDATE accounts SET balance = balance + 100 WHERE id = :id
-- idempotent version med et idempotency key
INSERT INTO account_credits (account_id, amount, key)
VALUES (:id, 100, :key)
ON CONFLICT (key) DO NOTHING;
Her vil du kun kreditere én gang pr. key, også selvom jobbet prøves flere gange.
Job-deduplikering med nøgler
Du kan også dedupe allerede ved tilføjelse til køen. F.eks. kun ét “send månedlig rapport”-job pr. bruger pr. måned.
const jobKey = `monthly-report:${userId}:${year}-${month}`
await jobsQueue.add("monthly-report", { userId }, { jobId: jobKey })
Mange queue-værktøjer vil behandle jobId som unik, så du ikke får 20 ens jobs hvis nogen spammer knappen.
Retries og dead-letter: stop uendelige loops før de starter
Retries lyder altid smukt: “vi prøver bare igen”. Men to klassiske ting går galt:
- jobbet er permanent defekt (poison message)
- du retrier alt for aggressivt og overbelaster en ekstern service
Et sundt retry-mønster
- Begrænset antal retries (fx 3-5)
- Exponential backoff (fx 1 min, 5 min, 30 min)
- Logning af sidste fejlårsag
jobsQueue.process("sync-to-crm", async (job) => {
try {
await syncToCrm(job.data)
} catch (err) {
if (isPermanentError(err)) {
throw new NonRetryableError(err)
}
throw err // vil blive retried
}
})
Dead-letter queue (DLQ)
Dead-letter queue er bare et fancy ord for: “jobs vi har givet op på, men ikke vil smide væk”.
- Jobs der har ramt max retries
- Jobs der fejlede med en NonRetryableError
DLQ’en kan du så kigge i, når der er tid, og håndtere manuelt eller med et separat script. Tænk den som “fejl-mappen” i din inbox, bare for jobs.
Observability: de få metrics der faktisk hjælper
Du behøver ikke et fuldt observability-setup for at få styr på dine baggrundsjobs, men der er nogle få ting, der gør en verden til forskel, når noget føles “langsomt” eller “ustabilt”.
1. Queue depth (antal jobs i køen)
Hvis dybden bare stiger og stiger, ved du to ting:
- der kommer flere jobs ind, end dine workers kan spise
- eller også er der et giftigt job der konstant fejler og retrier
2. Age (hvor gamle er jobsene?)
Hvor lang tid går der fra job oprettes til det faktisk kører?
- Hvis du lover “vi sender mailen inden for 1 minut”, skal du kunne se, om det passer
- Hvis din queue altid indeholder jobs der er timer gamle, har du et skaleringsproblem
3. Failure rate
Et simpelt tal per job-type er guld værd:
- hvor mange jobs fejler?
- hvor mange havner i dead-letter?
Det er samme mentalitet som når du kigger på logs og stacktraces: du vil kunne følge en konkret handling igennem systemet i stedet for at gætte.
Deployment: hvor skal workers bo?
Det sidste lille stykke af puslespillet: hvordan passer alt det her ind i din hosting?
1. Klassisk server / VM
Hvis du har en Node-app der kører på en VM eller container, er det forholdsvis simpelt:
- kør webserver og worker som to separate processer
- brug et process manager-tool (pm2, systemd, docker compose) til begge
Fordel: du har fuld kontrol. Ulempe: du skal selv holde øje med alt.
2. Serverless (Vercel, Netlify osv.)
Her bliver det lidt mere interessant, fordi du ikke bare kan starte en evigt-kørende worker-process.
Typiske mønstre:
- brug en hosted queue (fx SQS) og kør workers som separate “cron functions” der poller
- brug platformens egne scheduler-features til at trigge scripts med faste intervaller
Hvis du i forvejen slås lidt med hosting-valg, har jeg skrevet om det i “stop med at kæmpe med hosting”, men den korte version er: vælg det simpleste setup der kan køre dine jobs stabilt.
3. Hybrid: cron der føder queue
En model jeg godt kan lide i mindre projekter:
- cron-job kører hvert minut
- finder nye ting i databasen der skal behandles
- smider dem i queue i små batches
- workers tager sig af selve behandlingen
Det gør det nemt at styre, hvor mange nye jobs der bliver føjet til køen ad gangen, og du kan sætte limits per kørsel.
En lille huskeregel næste gang du tilføjer “bare lige” et job
Når du står i editoren og er ved at smide noget tungt ind i et endpoint, kan du bruge denne lille sekvens:
- Forventer jeg at det her er færdigt inden for 1 sekund?
- Kan jeg tåle at det fejler efter requesten er lykkedes?
- Er operationen idempotent, eller skal jeg bygge den sådan?
- Har jeg et naturligt sted at køre en worker eller cron i mit setup?
Hvis du svarer “nej” til 1 eller 2, er du allerede godt på vej over i queue- eller cron-land.
Jeg opdagede selv, at jeg havde bygget et lille baggrundsmonster, da en “uskyldig” import-funktion på en hobby-side fik min billige server til at gå i knæ hver gang en ven uploadede en CSV. Det tog mig en hel aften og alt for meget kaffe at rydde op, men resultatet var første gang, jeg fik en rigtig worker til at køre ved siden af min app. Siden da spørger jeg altid lige mig selv: “skal det her virkelig bo i requesten?” før jeg lader endnu et baggrundsjob flytte ind.









1 kommentar