---
title: "06 — Background Jobs: Event-Driven and Schedule-Driven Work"
uid: background-jobs
tags: ["scheduling", "event-driven", "background-jobs", "roadmap:system-design", "asynchronous", "system-design"]
excerpt: "A background job is work moved out of the user's request so it returns fast — triggered either by an event or by a schedule. Everything else is plumbing around those two triggers."
date: 2026-08-13T03:27:34+0000
source: https://www.aveshina.my.id/en/blog/background-jobs
---

"Just cron and queues" was my background-jobs model, and it hid the one property that defines the category. Writing it down gave it a shape: **a background job is work moved out of the user's request so the request can return fast, and it is triggered either by an event (something happened) or by a schedule (a timer fired).** [1] Everything else — queues, workers, results — is plumbing around those two triggers.

The framing that landed is that background jobs exist to fix a mismatch: the user wants a fast response, but the actual work (sending emails, generating reports, processing uploads, training a model) takes longer than any request should hold open [1]. So the request hands the work off and returns immediately, and a separate process does the work later. The two questions that define any background-job system are _when does the work start_ and _how does the result get back_.

## The two triggers: event-driven vs schedule-driven

The roadmap distinguishes the triggers cleanly, and that distinction is the whole point [1][2]:

```figure
<svg viewBox="0 0 740 260" xmlns="http://www.w3.org/2000/svg" class="my-6 w-full max-w-2xl" role="img" aria-label="Two triggers feeding a Worker. Left, Event-driven: a user action drops a message into a Queue, which the Worker reads. Right, Schedule-driven: a Clock fires on a timer and invokes the Worker. The Worker performs the job and stores or returns the result.">
  <defs>
    <marker id="bjarrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">
      <path d="M0,0 L10,5 L0,10 z" fill="#64748b"/>
    </marker>
  </defs>
  <g font-family="ui-sans-serif, system-ui, sans-serif" text-rendering="geometricPrecision">

    <!-- Event-driven lane -->
    <text x="160" y="24" font-size="12" font-weight="700" fill="#1e1b4b" text-anchor="middle">Event-driven</text>
    <text x="160" y="40" font-size="10" fill="#64748b" text-anchor="middle">something happened</text>
    <rect x="30" y="60" width="90" height="40" rx="8" fill="#e0e7ff" stroke="#6366f1" stroke-width="1.5"/>
    <text x="75" y="85" font-size="11" font-weight="700" fill="#1e1b4b" text-anchor="middle">user action</text>
    <rect x="160" y="60" width="90" height="40" rx="8" fill="#fef9c3" stroke="#ca8a04" stroke-width="1.5"/>
    <text x="205" y="85" font-size="11" font-weight="700" fill="#422006" text-anchor="middle">Queue</text>
    <path d="M120,80 L158,80" fill="none" stroke="#64748b" stroke-width="1.5" marker-end="url(#bjarrow)"/>
    <text x="139" y="73" font-size="9" fill="#475569" text-anchor="middle">enqueue</text>

    <!-- Schedule-driven lane -->
    <text x="555" y="24" font-size="12" font-weight="700" fill="#500724" text-anchor="middle">Schedule-driven</text>
    <text x="555" y="40" font-size="10" fill="#64748b" text-anchor="middle">a timer fired</text>
    <circle cx="510" cy="80" r="26" fill="none" stroke="#db2777" stroke-width="2"/>
    <line x1="510" y1="80" x2="510" y2="62" stroke="#db2777" stroke-width="2"/>
    <line x1="510" y1="80" x2="524" y2="80" stroke="#db2777" stroke-width="2"/>
    <text x="510" y="125" font-size="10" fill="#500724" text-anchor="middle">timer</text>
    <rect x="570" y="60" width="120" height="40" rx="8" fill="#fce7f3" stroke="#db2777" stroke-width="1.5"/>
    <text x="630" y="85" font-size="11" font-weight="700" fill="#500724" text-anchor="middle">cron / scheduler</text>
    <path d="M536,80 L568,80" fill="none" stroke="#64748b" stroke-width="1.5" marker-end="url(#bjarrow)"/>

    <!-- Worker (shared) -->
    <rect x="290" y="140" width="160" height="46" rx="8" fill="#dcfce7" stroke="#16a34a" stroke-width="2"/>
    <text x="370" y="168" font-size="13" font-weight="700" fill="#052e16" text-anchor="middle">Worker</text>
    <path d="M205,100 C250,130 280,150 288,158" fill="none" stroke="#64748b" stroke-width="1.5" marker-end="url(#bjarrow)"/>
    <path d="M630,100 C540,130 460,150 452,158" fill="none" stroke="#64748b" stroke-width="1.5" marker-end="url(#bjarrow)"/>

    <!-- Result -->
    <rect x="290" y="210" width="160" height="40" rx="8" fill="#e0e7ff" stroke="#6366f1" stroke-width="1.5"/>
    <text x="370" y="235" font-size="11" font-weight="700" fill="#1e1b4b" text-anchor="middle">result → storage / notify</text>
    <path d="M370,186 L370,208" fill="none" stroke="#64748b" stroke-width="1.5" marker-end="url(#bjarrow)"/>
  </g>
</svg>
```

- **Event-driven** triggers fire when something happens — the UI or another job drops a message in a queue, updates a value in storage, or calls an endpoint, and the background task reacts to it [1]. This is asynchronous, message-based communication: the request "place order" enqueues a "send confirmation email" job and returns; the worker picks it up and does the work. The trigger is the event itself.
- **Schedule-driven** triggers fire on a timer — a local timer, an external scheduler, or a one-shot delay [2]. Batch processing (update related-products lists overnight), routine maintenance (rebuild indexes, generate daily reports), data-retention cleanup, and consistency checks all live here. The trigger is the clock.

The diagnostic question I use: does this work need to happen _because something happened_ (event-driven) or _because it's time_ (schedule-driven)? Sending a welcome email is event-driven; generating the daily revenue report is schedule-driven. Mixing them up is how I once shipped an "every time the user signs up, also run the nightly report" bug.

## Returning results: fire-and-forget, mostly

The second question — how the result gets back — is where I had to adjust my intuition. A background job runs in a separate process, often a separate machine, from whatever invoked it. Ideally it is **fire-and-forget**: the caller does not wait, and the job's progress has no impact on the caller [3]. That means the caller cannot automatically detect when the job ends.

So results come back through side channels, not return values:

- The worker writes the result to storage (a database row, an object store) and the caller polls or is notified.
- The worker publishes a completion event on a queue, and an interested consumer reacts.
- For user-facing jobs, the worker updates a status field the UI renders ("Your video is processing…").

The pattern is: **the job does not return a value to the caller; it produces a side effect the caller can observe later.** That is the whole reason it can run asynchronously.

## How I use this

The decision check is whether work belongs on the request path. If a request does something expensive that the user does not need to wait for — sending notifications, resizing an upload, updating a derived dataset, anything that calls a flaky third-party API — it goes into a background job. I then pick the trigger by cause (event vs schedule), and I make the result observable through storage or a status field rather than a return value. The discipline of moving slow work off the request path is, more than any single tool, what keeps response times acceptable as a system grows.

## References

[1] Microsoft, "Background jobs — best practices," Azure Architecture Center. [Online]. Available: [https://learn.microsoft.com/en-us/azure/architecture/best-practices/background-jobs](https://learn.microsoft.com/en-us/azure/architecture/best-practices/background-jobs)

[2] Microsoft, "Background jobs — schedule-driven triggers," Azure Architecture Center. [Online]. Available: [https://learn.microsoft.com/en-us/azure/architecture/best-practices/background-jobs#schedule-driven-triggers](https://learn.microsoft.com/en-us/azure/architecture/best-practices/background-jobs#schedule-driven-triggers)

[3] Microsoft, "Background jobs — returning results," Azure Architecture Center. [Online]. Available: [https://learn.microsoft.com/en-us/azure/architecture/best-practices/background-jobs#returning-results](https://learn.microsoft.com/en-us/azure/architecture/best-practices/background-jobs#returning-results)

[4] Microsoft, "Background jobs — event-driven triggers," Azure Architecture Center. [Online]. Available: [https://learn.microsoft.com/en-us/azure/architecture/best-practices/background-jobs#event-driven-triggers](https://learn.microsoft.com/en-us/azure/architecture/best-practices/background-jobs#event-driven-triggers)

```quiz
Q: A welcome email should be sent when a user signs up. The trigger is…
- schedule-driven (a timer)
- event-driven (the signup happened)
correct: 1
explain: The signup is an event, so the email job is event-driven. Schedule-driven triggers fire on a timer regardless of user activity.

Q: A background job runs in a separate process from the caller. How does the caller typically get the result?
- via the job's return value
- via a side effect the caller observes later (storage row, status field, completion event)
correct: 1
explain: Background jobs are fire-and-forget; the caller does not wait and cannot receive a return value. Results come back through side channels like storage or status updates.

Q: Generating the daily revenue report at 2am is best triggered…
- by an event
- by a schedule
correct: 1
explain: Time-based batch work like nightly reports is the canonical schedule-driven case — it runs because it's time, not because something happened.

Q: Why move work into a background job at all?
- to keep the user's request fast by not holding it open for expensive work
- because background jobs always run faster than foreground work
correct: 0
explain: The point is decoupling expensive work from the request path so the request returns quickly. The job is not faster in absolute terms; it just doesn't make the user wait.
```
