The Dinner Rush: Building a Resilient Kitchen


Our restaurant is now a model of modern efficiency. We can handle complex orders and manage live state (StateFlow) and events (SharedFlow). But let’s agree, we’ve been operating under ideal conditions.

What happens when the Saturday night dinner rush hits?

  • The kitchen (producer) starts churning out dishes far faster than the waiters (consumers) can deliver them.
  • A specialized task like butchering meat (IO-intensive) is done right next to the delicate plating station (UI-bound), causing chaos :(.
  • One dish gets burnt, and the entire buffet line shuts down in response.

To survive the rush, our kitchen needs to be more than just efficient. It needs to be resilient.

Backpressure: When the Chef is Too Fast


Backpressure is what happens when a producer emits items faster than a consumer can process them. By default, Flow is sequential. The chef waits for the waiter to deliver one dish before starting the next. This is safe but not always performant.

Let’s see example for fast chef and a slow waiter:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
fun makeDishesQuickly(): Flow<Int> = flow {
    repeat(5) { dishNumber ->
        println("Chef: Cooking dish $dishNumber")
        // Chef is fast
        delay(100)
        emit(dishNumber)
    }
}

val startTime = System.currentTimeMillis()
makeDishesQuickly().collect { dish ->
    println("Waiter: Serving dish $dish...")
    // Waiter is slow
    delay(500)
}
println("Total time: ${System.currentTimeMillis() - startTime}ms")

Output:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
Chef: Cooking dish 0
Waiter: Serving dish 0...
Chef: Cooking dish 1
Waiter: Serving dish 1...
Chef: Cooking dish 2
Waiter: Serving dish 2...
Chef: Cooking dish 3
Waiter: Serving dish 3...
Chef: Cooking dish 4
Waiter: Serving dish 4...
Total time: 3000ms
// (5 dishes * (100ms cook + 500ms serve))

The chef is constantly blocked, waiting for the slow waiter. We can do better.

Strategy 1: buffer() - The Warming Table

The buffer() operator runs the producer coroutine concurrently with the consumer, with a buffer in between. The chef can place dishes on a warming table without waiting for the waiter.

Buffer
1
2
3
4
5
6
makeDishesQuickly()
    .buffer()
    .collect { dish -> 
        println("Waiter: Serving dish $dish...")
        delay(500)
    }

Output:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
Chef: Cooking dish 0
Chef: Cooking dish 1
Chef: Cooking dish 2
Chef: Cooking dish 3
Chef: Cooking dish 4
Waiter: Serving dish 0...
Waiter: Serving dish 1...
Waiter: Serving dish 2...
Waiter: Serving dish 3...
Waiter: Serving dish 4...
Total time: 2600ms 
// Chef finishes in ~500ms, Waiter takes ~2500ms. Much faster!

The chef finishes cooking almost instantly, and the total time is now dominated only by the slow waiter.

Strategy 2: conflate() - The “Latest Special” Board

What if we only care about the most recent value? conflate() is a strategy where a slow consumer skips intermediate values. If the chef puts down three new dishes while the waiter is busy, the waiter will ignore the first two and just deliver the latest one.

Conflate
1
2
3
4
5
6
7
makeDishesQuickly()
    // Only deliver the latest dish
    .conflate()
    .collect { dish ->
        println("Waiter: Serving dish $dish...")
        delay(500)
    }

Output:

1
2
3
4
5
6
7
8
9
Chef: Cooking dish 0
Chef: Cooking dish 1
Chef: Cooking dish 2
Chef: Cooking dish 3
Chef: Cooking dish 4
Waiter: Serving dish 0... // By the time this is done, chef is at dish 4
Waiter: Serving dish 4... // Skips 1, 2, and 3
Total time: 1100ms
// Super fast, but with data loss

This collector processes only the latest value, but it goes a step further. If a new value arrives while the previous one is being processed, it cancels the old processing block and starts over with the new value. This is the perfect pattern for handling rapid-fire UI events like search queries.

CollectLatest
1
2
3
4
5
6
7
makeDishesQuickly()
    // It's a collector, not an operator !
    .collectLatest { dish ->
        println("Waiter: Grabbing dish $dish...")
        delay(500)
        println("Waiter: FINISHED serving dish $dish.")
    }

Output:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
Chef: Cooking dish 0
Waiter: Grabbing dish 0...
Chef: Cooking dish 1
Waiter: Grabbing dish 1... // Cancels serving dish 0
Chef: Cooking dish 2
Waiter: Grabbing dish 2... // Cancels serving dish 1
Chef: Cooking dish 3
Waiter: Grabbing dish 3... // Cancels serving dish 2
Chef: Cooking dish 4
Waiter: Grabbing dish 4... // Cancels serving dish 3
Waiter: FINISHED serving dish 4.
// Only the last one completes!
Total time: 1000ms

flowOn(): Keeping the Kitchen Organized


By default, the producer and the collector run in the same coroutine and on the same thread. This can be a problem if the chef is doing heavy work (like butchering on an IO thread) that shouldn’t happen at the delicate plating station (Main UI thread).

The flowOn() operator changes the execution context for the upstream code (the producer and any operators before it).

FlowOn
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
flow {
    println("Chef: Prepping ingredients. [Thread: ${Thread.currentThread().name}]")
    emit("Dish")
}
.map { dish -> 
    println("Sous-chef: Decorating the dish. [Thread: ${Thread.currentThread().name}]")
    "$dish with Decoration"
}
.flowOn(Dispatchers.IO)

// Everything ABOVE runs on the IO Dispatcher

.collect { dish ->
    println("Waiter: Serving '$dish'. [Thread: ${Thread.currentThread().name}]")
}

Output:

1
2
3
Chef: Prepping ingredients. [Thread: DefaultDispatcher-worker-1]
Sous-chef: Decorating the dish. [Thread: DefaultDispatcher-worker-1]
Waiter: Serving 'Dish with Decoration'. [Thread: main]

flowOn acts as a boundary. The heavy kitchen work stays off the main thread.

catch(): Handling a Burnt Dish


What happens if something goes wrong in the stream? By default, an exception will terminate the flow and crash the collector.

1
2
3
4
5
6
7
8
flow {
    emit("Salad")
    emit("Bread")
    throw RuntimeException("Burnt the steak!")
    emit("Dessert")
}
// This would crash!
.collect { dish -> println("Enjoying the $dish") }

The catch operator provides a declarative way to handle upstream exceptions.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
flow {
    emit("Salad")
    emit("Bread")
    throw RuntimeException("Burnt the steak!")
}
.catch { e -> 
    println("Inspector: Caught a problem! ${e.message}")
    // Can even emit a replacement value
    emit("Compensatory Cookies")
}
.collect { dish -> 
    println("Customer: Enjoying the $dish") 
}

Output:

1
2
3
4
Customer: Enjoying the Salad
Customer: Enjoying the Bread
Inspector: Caught a problem! Burnt the steak!
Customer: Enjoying the Compensatory Cookies

The stream didn’t crash. The error was handled gracefully.

Note: catch can only handle exceptions from upstream operators. It can’t catch an exception in the collect block itself !

Wrap-up

The kitchen is now officially resilient and ready for the dinner rush.

  • Backpressure Strategies: Manages fast producers and slow consumers using buffer (concurrency), conflate (latest value), and collectLatest (cancellable work).
  • flowOn: Assignes specific parts of stream to the correct context, keeping code organized and UI responsive.
  • catch: Use to handle errors declaratively within stream, preventing crashes and allowing for recovery.

What’s Next in Part 5?

We’ve designed an incredible restaurant, trained our staff, and built resilient systems. But how do we prove it all works without opening for business? How can we be sure the chef’s timing is right and the waiter’s logic is good?

In the final part, we will dive into the essential topic of Testing Coroutines and Flows. We’ll explore the kotlinx-coroutines-test library, learn how to control virtual time, and write stable, reliable tests for our asynchronous world.