Skip to main content

async and await: Sending 10 Fee Reminders at Once – C# | Lesson 53

Published / updated

Introduction​

Most of what an application does is wait: for the database to answer, for a file to be read, for a message provider to accept a text. While a program waits, it is doing nothing useful, yet it can be holding on to resources other work needs.

Asynchronous code lets a program start a slow operation, step away while it runs, and continue when the result arrives. In C#, two keywords make this read almost like normal code: async and await.

By the end of this lesson, you will be able to:

  • Explain what asynchronous code is for, and what it is not for.
  • Write methods that return Task and Task<T>, using async and await.
  • Run independent operations together with Task.WhenAll.
  • Cancel slow work with a CancellationToken.
  • Handle exceptions from tasks, and avoid async void and blocking calls.

Why this matters​

The Web API in Modules 10 to 12 is asynchronous from top to bottom. Each request reads the database with await, and while it waits, the server uses the same thread to start other requests. That is how a modest server handles hundreds of parents at 8:45 in the morning.

Asynchronous code also changes how long tasks feel. Sending 10 fee reminders one after another took about 2.6 seconds in this lesson; sending them together took about 0.26 seconds. For 10,000 students, that is the difference between minutes and seconds.

The concept​

Example: waiting without blocking​

Think of the school office sending ten fee letters by courier. The slow way: hand over one letter, wait at the door until the courier returns, then hand over the next.

The sensible way: hand all ten to the courier, go back to other work, and check the delivery receipts later. The letters travel at the same speed either way; the difference is whether the clerk stands idle while they travel. Asynchronous code is the sensible clerk.

Example: Task and Task<T>​

A task represents work that will finish later. It is a promise of a result:

TypeMeansExample
TaskWork that finishes with no resultWriting a log file
Task<string>Work that finishes with a stringSending a message, returning a status
Task<string[]>Work that finishes with an arrayTask.WhenAll of many Task<string>

A method marked async can use await inside, and returns a Task or Task<T>. By convention, its name ends with Async, as in SendAsync and File.ReadAllLinesAsync.

Example: what await does​

await means "if this task is not finished yet, pause this method here, give the thread back, and continue from this exact line when the task completes".

The method looks sequential, line after line, but no thread sits blocked during the wait. The compiler turns the method into a small state machine that remembers where to continue. You write simple code; the compiler writes the complicated part.

Example: I/O-bound versus CPU-bound​

Kind of workExampleRight tool
I/O-bound: waiting for something outsideDatabase query, web request, file readasync and await
CPU-bound: the processor is busy calculatingAttendance percentages for 600,000 rowsParallel work on several cores (Lesson 54)

async does not make calculations faster. It makes waiting cheaper. The two ideas are often confused in interviews, so keep this table in mind.

Apply it to the SMS​

Step 1: a gateway that takes time​

Real text messages go through a provider's web service over the network. Here, a stand-in waits 250 milliseconds per message with Task.Delay, an asynchronous pause that holds no thread:

src/SchoolApp.Console/TextMessageGateway.cs
// Lesson 53: a stand-in for a text-message provider. A real provider is called over the network,
// so each message takes time; Task.Delay pretends to be that wait without blocking a thread.
class TextMessageGateway
{
public int Sent { get; private set; }

public async Task<string> SendAsync(string phone, string text, CancellationToken cancellationToken = default)
{
if (phone.Length != 10)
{
throw new ArgumentException($"Invalid phone number: {phone}", nameof(phone));
}

await Task.Delay(250, cancellationToken); // the "network" wait
Sent++;
return $"sent to {phone}";
}
}

Step 2: the program​

The reminders go to the ten families who have a balance due in SchoolDb: Diya's family owes 25,500, four families 45,500, and five families 39,500.

Show src/SchoolApp.Console/Program.cs
src/SchoolApp.Console/Program.cs
// Lesson 53: async and await. Fee reminders for the 10 families with a balance due in SchoolDb.
using System.Diagnostics;

// 1. Real asynchronous I/O: the thread is free while the disk works.
string path = Path.Combine(AppContext.BaseDirectory, "data", "class10-marks.csv");
string[] lines = await File.ReadAllLinesAsync(path);
Console.WriteLine($"Read {lines.Length - 1} marks asynchronously");

Reminder[] reminders =
[
new("Suresh Patel", "9876540103", "Diya", 25500m),
new("Harpreet Singh", "9876540105", "Kabir", 45500m),
new("Venkat Iyer", "9876540107", "Meera", 45500m),
new("Amit Das", "9876540109", "Rohan", 45500m),
new("Imtiaz Sheikh", "9876540111", "Sana", 45500m),
new("Ravi Reddy", "9876540113", "Ananya", 39500m),
new("Karan Kapoor", "9876540115", "Vihaan", 39500m),
new("Faisal Ali", "9876540117", "Zara", 39500m),
new("Sanjay Mishra", "9876540119", "Arnav", 39500m),
new("Joseph Fernandes", "9876540121", "Kiara", 39500m),
];

// 2. One after another: each await waits for the previous message.
TextMessageGateway gateway = new TextMessageGateway();
Stopwatch timer = Stopwatch.StartNew();
foreach (Reminder r in reminders)
{
await gateway.SendAsync(r.Phone, r.Text);
}
Console.WriteLine($"One by one: {gateway.Sent} messages in {timer.ElapsedMilliseconds} ms");

// 3. All at once: start every send, then await them together.
gateway = new TextMessageGateway();
timer.Restart();
Task<string>[] sends = reminders.Select(r => gateway.SendAsync(r.Phone, r.Text)).ToArray();
string[] results = await Task.WhenAll(sends);
Console.WriteLine($"Together: {results.Length} messages in {timer.ElapsedMilliseconds} ms; first: {results[0]}");

// 4. Cancellation: give up after 600 ms.
gateway = new TextMessageGateway();
using CancellationTokenSource timeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(600));
try
{
foreach (Reminder r in reminders)
{
await gateway.SendAsync(r.Phone, r.Text, timeout.Token);
}
}
catch (OperationCanceledException)
{
Console.WriteLine($"Cancelled after {gateway.Sent} messages");
}

// 5. One failing task: await Task.WhenAll throws, and the other tasks still finish.
gateway = new TextMessageGateway();
Task<string>[] mixed =
[
gateway.SendAsync("9876540103", "Test 1"),
gateway.SendAsync("98765", "Test 2"), // a mistyped number
gateway.SendAsync("9876540107", "Test 3"),
];
try
{
await Task.WhenAll(mixed);
}
catch (ArgumentException ex)
{
Console.WriteLine($"Failed: {ex.Message}; completed anyway: {mixed.Count(t => t.IsCompletedSuccessfully)}");
}

record Reminder(string Guardian, string Phone, string Student, decimal Balance)
{
public string Text => $"{Student}: fee balance {Balance}. NexCoding Public School";
}
Output
Read 12012 marks asynchronously
One by one: 10 messages in 2605 ms
Together: 10 messages in 262 ms; first: sent to 9876540103
Cancelled after 2 messages
Failed: Invalid phone number: 98765 (Parameter 'phone'); completed anyway: 2

Three runs gave 2,605, 2,587, and 2,595 ms one by one, and 262, 263, and 256 ms together.

Example: await in a loop runs one at a time​

In the foreach, each await waits for one message before the loop moves on. Ten messages of 250 ms each take about 2.6 seconds: the sum of all the waits.

That is correct when each step needs the previous one, such as "save the payment, then send the receipt". For independent messages, it wastes time.

Example: Task.WhenAll runs them together​

reminders.Select(r => gateway.SendAsync(...)).ToArray() starts all ten sends without awaiting them, giving ten running tasks. await Task.WhenAll(sends) then waits once for all of them, and returns their ten results in the same order.

The total time is about the time of the slowest single message: 262 ms instead of 2,605. Nothing ran faster; the waits overlapped.

Example: cancellation​

A CancellationTokenSource with a 600 ms time limit produced a token, passed down to SendAsync and from there to Task.Delay. When the time ran out, the waiting task stopped with an OperationCanceledException, and the loop ended after two messages.

Every asynchronous method should accept a CancellationToken and pass it on. In the Web API, ASP.NET Core cancels the token when a parent closes the browser, so the server stops working on a page nobody will read.

Example: when one task fails​

The second of three sends had a mistyped number, 98765. await Task.WhenAll(mixed) threw that ArgumentException, but the other two tasks still completed: completed anyway: 2.

WhenAll waits for every task, then rethrows the first failure. To see every failure, inspect each task's status or Exception afterwards. Design batch sends so one bad number never stops the whole batch.

Example: a counter that is not safe​

TextMessageGateway.Sent adds 1 after every message. In the "together" run, ten continuations can finish at nearly the same moment, on different threads.

Sent++ is not safe in that situation, which is why that demo prints results.Length instead. Lesson 54 shows exactly what goes wrong, and how to fix it.

Example: rules for async code​

RuleWhy
Async all the way: if you call an async method, await itBlocking with .Result or .Wait() wastes a thread, and can deadlock in older frameworks
Never async void, except event handlersNobody can await it, and its exceptions crash the process
Pass a CancellationToken through every async methodLets callers stop work nobody needs
End async method names with AsyncCallers can see they must await it

Forgetting await compiles, but with a warning: CS4014: Because this call is not awaited, execution of the current method continues before the call is completed. Using await in a method without async fails with CS4033.

Common mistakes​

  1. Blocking on async code with .Result or .Wait(). Threads stand idle, and in UI or older ASP.NET apps it can deadlock. Fix: await all the way up.
  2. async void methods. Exceptions cannot be caught by the caller. Fix: return Task.
  3. Awaiting independent work one at a time. Waits add up. Fix: start all, then Task.WhenAll.
  4. Ignoring cancellation. Work continues after nobody needs it. Fix: accept and pass a CancellationToken.
  5. Using async for calculations. It does not add speed. Fix: parallelism for CPU-bound work.

In real projects​

From 16 years of .NET projects

On a busy results day, a school portal stopped responding although the server's processor was almost idle. Every request called the database with .Result, blocking a thread for each wait.

The thread pool ran out of threads, a problem called thread pool starvation. New requests queued up behind threads that were doing nothing but waiting.

Making the data access async all the way, with no other change, let the same server handle the load. Since then, any .Result or .Wait() in web code is a review comment from me.

Best practices​

  • Use async and await for I/O: databases, files, and network calls.
  • Await all the way up; never block with .Result or .Wait().
  • Run independent operations together with Task.WhenAll.
  • Accept a CancellationToken in every async method, and pass it on.
  • Return Task or Task<T>; use async void only for event handlers.

Recap​

  • Asynchronous code frees the thread while waiting for I/O.
  • An async method returns Task or Task<T>; await pauses it without blocking.
  • Awaiting in a loop runs one at a time (2.6 s); Task.WhenAll overlaps the waits (0.26 s).
  • A CancellationToken stops work that is no longer needed.
  • WhenAll rethrows the first failure after all tasks finish.
  • async helps waiting, not calculating; CPU-bound work is Lesson 54.

Homework​

Library module task: When The Jungle Book comes back, notify Kabir, Meera, and Rohan at the same time with Task.WhenAll, each notice taking 300 ms. Write the notices to a log file with File.WriteAllLinesAsync and read them back.

Then send a notice with a 100 ms time limit, and show that it is cancelled.

A correct result looks like:

Output
3 notices in about the time of one
Logged 3 lines; first: The Jungle Book is ready for Kabir Singh
Notice to Sana Sheikh cancelled: the 100 ms time limit passed
Show the solution
library-assignment/053-library-async.cs
// Library homework, Lesson 53: notify waiting students at the same time, then log asynchronously.
// Run with: dotnet run library-assignment/053-library-async.cs
using System.Diagnostics;

string[] waiting = ["Kabir Singh", "Meera Iyer", "Rohan Das"];

Stopwatch timer = Stopwatch.StartNew();
string[] results = await Task.WhenAll(waiting.Select(student => NotifyAsync(student, "The Jungle Book")));
Console.WriteLine($"{results.Length} notices in about {(timer.ElapsedMilliseconds < 500 ? "the time of one" : "the time of all")}");

string logPath = Path.Combine(Path.GetTempPath(), "library-notices.log");
await File.WriteAllLinesAsync(logPath, results);
string[] logged = await File.ReadAllLinesAsync(logPath);
Console.WriteLine($"Logged {logged.Length} lines; first: {logged[0]}");

using CancellationTokenSource timeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(100));
try
{
await NotifyAsync("Sana Sheikh", "Malgudi Days", timeout.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("Notice to Sana Sheikh cancelled: the 100 ms time limit passed");
}

static async Task<string> NotifyAsync(string student, string title, CancellationToken cancellationToken = default)
{
await Task.Delay(300, cancellationToken); // pretend to send a message
return $"{title} is ready for {student}";
}

The log goes to the system's temporary folder, Path.GetTempPath(), so the homework never leaves files inside the repository.

Quiz​

  1. What does await do when the task is not finished?

    1. Blocks the thread until it finishes
    2. Pauses the method and frees the thread until the task completes
    3. Cancels the task
    4. Starts a new thread
    Show answer

    Answer: B. Pauses the method and frees the thread until the task completes The method continues from the same line when the task completes.

  2. Ten independent 250 ms sends: which is fastest?

    1. await each in a foreach
    2. Start all, then await Task.WhenAll
    3. Call .Result on each
    4. Use async void
    Show answer

    Answer: B. Start all, then await Task.WhenAll The waits overlap: about 260 ms instead of 2.6 seconds.

  3. Which kind of work does async speed up?

    1. Heavy calculations
    2. Waiting for I/O such as databases and networks
    3. Sorting lists
    4. Nothing
    Show answer

    Answer: B. Waiting for I/O such as databases and networks Async makes waiting cheap; it does not make the processor faster.

  4. Why avoid async void?

    1. It is slower
    2. Callers cannot await it, and its exceptions can crash the process
    3. It needs .NET 10
    4. It cannot use await
    Show answer

    Answer: B. Callers cannot await it, and its exceptions can crash the process Return Task instead; async void is only for event handlers.

  5. One of three tasks fails. What does await Task.WhenAll do?

    1. Stops the other two immediately
    2. Waits for all, then throws the failure
    3. Ignores the failure
    4. Returns null
    Show answer

    Answer: B. Waits for all, then throws the failure The other two completed anyway in the demo.

Interview questions​

Questions are grouped by experience level. Click a question to see a model answer. All questions: Advanced C# interview questions.

Fresher (0-2 yrs)​

F What do async and await do? concept

async marks a method that can use await and returns Task or Task<T>. await pauses the method until a task completes, giving the thread back in the meantime, and continues from the same line afterwards.

See the example · Permalink

F What is the difference between Task and Task<T>? concept

Both represent work that finishes later. Task has no result, like writing a log file; Task<T> produces a value of type T, like Task<string> for a send status.

See the example · Permalink

F Does async make code run faster? concept

Not by itself. It makes waiting for I/O cheaper by freeing threads, which improves scalability and lets independent waits overlap. CPU-bound calculations need parallelism instead.

See the example · Permalink

Mid (3-5 yrs)​

M How do you run several independent async operations together? coding

Start them without awaiting, collect the tasks, and await Task.WhenAll(tasks). Ten 250 ms sends took about 260 ms together instead of 2.6 seconds one by one. Results come back in the same order.

See the example · Permalink

M What is a CancellationToken, and why pass it through async methods? concept

A token that signals that work should stop, created by a CancellationTokenSource, for example with a time limit. Passing it down to every async call lets the whole operation stop early; ASP.NET Core cancels it when the client disconnects.

See the example · Permalink

M Why should you avoid async void? concept

An async void method cannot be awaited, so callers cannot know when it ends, and exceptions it throws cannot be caught by the caller and can crash the process. Return Task instead; use async void only for event handlers.

See the example · Permalink

Senior (6-9 yrs)​

S A web portal stops responding under load, but the CPU is nearly idle. The code uses .Result on database calls. What is happening? scenario

Thread pool starvation: each request blocks a thread while waiting for the database, so the pool runs out and new requests queue. Make the call chain async all the way with await, so threads are released during waits.

How to answer: Explain the symptom (low CPU, high latency), the cause (blocked threads), the fix, and how to confirm it with thread pool counters or dotnet-counters.

See the example · Permalink

S How are exceptions handled with Task.WhenAll? concept

WhenAll waits for all tasks to finish, then await rethrows the first exception; the other tasks still complete. Inspect each task's status or the aggregate Exception to see all failures, and design batches so one failure does not stop the rest.

How to answer: Show awareness that only the first exception surfaces through await, and how to collect all errors for reporting in batch jobs.

See the example · Permalink

Architect / Lead (10+ yrs)​

A How would you send fee reminders to 10,000 families reliably and quickly? design

Send asynchronously with limited concurrency (for example batches or a semaphore) so the provider is not overwhelmed, with timeouts and cancellation, retries for temporary failures, and a record of what was sent so a restart does not send twice. Run it as a background job, not inside a web request.

How to answer: Cover throughput (bounded concurrency), reliability (retries, idempotency, persistence), provider limits, and observability. Mention background workers in Module 12.

See the example · Permalink

Resources​

  • Code for this lesson: git tag lesson-53-async-await (code repository coming soon)
  • Files: src/SchoolApp.Console/TextMessageGateway.cs, src/SchoolApp.Console/Program.cs, library-assignment/053-library-async.cs
  • Video: coming soon

What's next​

Async made waiting cheap. Next: making heavy calculations faster by using every processor core, and the race conditions that appear when several threads share one counter. Lesson 54: Threading

Want live sessions, code reviews, and interview preparation? Join the next batch at Sahasra Technologies.