asynchronous-programming
design-patterns
task-scheduling
dependencies
concurrency

Design pattern for checking asynchronous task dependencies before execution

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

When asynchronous work has prerequisites, the real problem is not just concurrency but coordination. A reliable design treats tasks and their dependencies as explicit data, then lets a scheduler release each task only after all of its upstream requirements have completed successfully.

Model the workflow as a dependency graph

The cleanest pattern is to represent each task as a node and each prerequisite as a directed edge. That gives you a directed acyclic graph, or DAG, for normal workflows.

For each task, store at least:

  • a unique task identifier
  • the list of dependency task identifiers
  • the current state, such as pending, running, done, or failed
  • the function that actually performs the work

Once the dependencies are explicit, the scheduler no longer has to guess execution order. It can ask a simple question for every task: are all dependency states done.

This is better than scattering await chains throughout the codebase because the orchestration logic stays centralized and testable.

Gate execution with readiness checks

The core rule is that a task becomes runnable only when every dependency has finished successfully. You can implement that with a readiness predicate.

typescript
1type TaskState = "pending" | "running" | "done" | "failed";
2
3type Task = {
4  id: string;
5  deps: string[];
6  run: () => Promise<void>;
7  state: TaskState;
8};
9
10function isReady(task: Task, tasks: Map<string, Task>): boolean {
11  return task.deps.every((depId) => tasks.get(depId)?.state === "done");
12}

This readiness check is the design pattern in its smallest useful form. Everything else is scheduling policy.

A simple dependency-aware scheduler

A scheduler can repeatedly scan for tasks that are still pending and ready to run. When it finds one, it runs it and updates the state.

typescript
1async function runTasks(tasks: Task[]): Promise<void> {
2  const byId = new Map(tasks.map((task) => [task.id, task]));
3
4  while (true) {
5    const runnable = tasks.filter(
6      (task) => task.state === "pending" && isReady(task, byId)
7    );
8
9    if (runnable.length === 0) {
10      const unfinished = tasks.some(
11        (task) => task.state === "pending" || task.state === "running"
12      );
13
14      if (unfinished) {
15        throw new Error("No runnable tasks remain. Check for cycles or failed prerequisites.");
16      }
17      break;
18    }
19
20    await Promise.all(
21      runnable.map(async (task) => {
22        task.state = "running";
23        try {
24          await task.run();
25          task.state = "done";
26        } catch (error) {
27          task.state = "failed";
28          throw error;
29        }
30      })
31    );
32  }
33}

This version runs all currently ready tasks in parallel. That is often what you want: dependencies enforce order where needed, and unrelated tasks are still concurrent.

Example workflow

Assume a reporting pipeline with four tasks:

  • 'fetch-users'
  • 'fetch-orders'
  • 'build-report, which depends on both fetch tasks'
  • 'upload-report, which depends on build-report'
typescript
1const tasks: Task[] = [
2  {
3    id: "fetch-users",
4    deps: [],
5    state: "pending",
6    run: async () => console.log("users loaded")
7  },
8  {
9    id: "fetch-orders",
10    deps: [],
11    state: "pending",
12    run: async () => console.log("orders loaded")
13  },
14  {
15    id: "build-report",
16    deps: ["fetch-users", "fetch-orders"],
17    state: "pending",
18    run: async () => console.log("report built")
19  },
20  {
21    id: "upload-report",
22    deps: ["build-report"],
23    state: "pending",
24    run: async () => console.log("report uploaded")
25  }
26];
27
28runTasks(tasks).catch(console.error);

The scheduler will run the two fetch tasks first, then build-report, then upload-report. You do not hardcode that sequence anywhere else.

Why this pattern scales better than ad hoc chaining

For two or three operations, nested await calls are fine. The trouble starts when the dependency graph is no longer a straight line.

Examples:

  • one task waits on two upstream tasks
  • many tasks share the same prerequisite
  • some tasks can run in parallel while others must serialize
  • failures should block only downstream tasks, not unrelated branches

A graph-based scheduler handles those cases naturally. It also makes it easier to add features such as retries, priority, concurrency limits, and persistence.

Production refinements

In real systems, you usually add at least one of these:

  • cycle detection before execution begins
  • failure propagation so downstream tasks become blocked
  • retry rules for transient failures
  • idempotency so a restarted scheduler does not corrupt state
  • durable task state in a database or queue

At larger scale, job orchestrators such as Airflow, Temporal, Argo Workflows, and similar systems package these ideas into full platforms. The underlying pattern is still the same: represent dependencies explicitly and release work only when prerequisites are satisfied.

Common Pitfalls

A common mistake is hiding dependency checks inside each task instead of the scheduler. That spreads orchestration logic across the codebase and makes failures harder to reason about.

Another mistake is ignoring cycles. If task A depends on B and B depends on A, the scheduler can stall forever unless you validate the graph first.

A third mistake is treating "dependency completed" and "dependency succeeded" as the same thing. Downstream tasks should usually wait for successful completion, not merely finished execution.

Summary

  • Represent asynchronous workflows as tasks plus explicit dependency lists.
  • Release a task only when all prerequisite tasks are in a successful state.
  • A dependency-aware scheduler can run independent tasks in parallel without breaking ordering rules.
  • Centralized orchestration is easier to test and extend than scattered await chains.
  • Add cycle checks, failure handling, and persistence for production systems.

Course illustration
Course illustration

All Rights Reserved.