Skip to main content
Coroutines & Flow Architecture

Crafting Reactive Canvases: Advanced Coroutine and Flow Architecture Patterns

Reactive canvases—think collaborative whiteboards, real-time drawing tools, or live data visualization surfaces—demand more from coroutines and Flow than typical UI updates. Gesture events arrive in bursts, rendering must keep pace without dropping frames, and state synchronization across layers can spiral into complexity. This guide assumes you already know the basics of structured concurrency and Flow operators. We'll focus on the architectural patterns that make reactive canvases feel responsive and remain maintainable over time, along with the pitfalls that often cause teams to rewrite their entire pipeline. The Real-World Context of Reactive Canvases Reactive canvases appear in a surprising range of applications: digital whiteboards, image editors, map annotation tools, and live charting dashboards. What they share is a continuous stream of user input—touch, mouse, stylus—that must be translated into visual output with minimal latency.

Reactive canvases—think collaborative whiteboards, real-time drawing tools, or live data visualization surfaces—demand more from coroutines and Flow than typical UI updates. Gesture events arrive in bursts, rendering must keep pace without dropping frames, and state synchronization across layers can spiral into complexity. This guide assumes you already know the basics of structured concurrency and Flow operators. We'll focus on the architectural patterns that make reactive canvases feel responsive and remain maintainable over time, along with the pitfalls that often cause teams to rewrite their entire pipeline.

The Real-World Context of Reactive Canvases

Reactive canvases appear in a surprising range of applications: digital whiteboards, image editors, map annotation tools, and live charting dashboards. What they share is a continuous stream of user input—touch, mouse, stylus—that must be translated into visual output with minimal latency. The challenge is that input events can arrive at hundreds per second, while the rendering pipeline (whether Canvas, WebGL, or custom GPU compute) might only refresh at 60 or 120 Hz. Bridging this gap without losing events or overwhelming the renderer is where coroutine and Flow architecture shines—or breaks.

In a typical project, the input layer emits events as they occur, often on the main thread or a dedicated input thread. A naive approach collects these events into a list and passes them to the renderer frame by frame. But this quickly leads to problems: if the renderer is busy, events pile up, causing memory pressure and input lag. Worse, if the renderer runs on the main thread, any long operation—like decoding an image or computing a bezier path—blocks the entire canvas. Teams often start with a simple MutableStateFlow<List<InputEvent>> and collect in a composable or view, only to find that the UI stutters or events are dropped silently.

The architectural response is to decouple input acquisition from rendering using structured concurrency. A common pattern involves a CoroutineScope tied to the canvas lifecycle, with two child coroutines: one that collects input events into a Channel with a bounded capacity, and another that reads from that channel and batches events for each frame. The channel acts as a buffer that naturally applies backpressure—if the renderer falls behind, the channel's send suspends until there's room, slowing input collection without losing data. This is a textbook example of using channels as a concurrency primitive, but the details matter: choosing the right capacity, deciding whether to use Channel.Factory.CONFLATED or a regular buffer, and handling cancellation when the canvas goes off-screen.

Another real-world scenario is collaborative canvases, where input events from multiple users must be merged, conflict-resolved, and rendered consistently. Here, Flow operators like combine or flatMapMerge become essential, but they also introduce ordering challenges. One approach is to timestamp each event and use a merge with a custom comparator, but that can be expensive. A more robust pattern is to assign each user a dedicated channel and merge them with flatMapMerge, then apply a conflict-resolution function before rendering. The key insight is that the rendering layer should never see raw events—it should see a consolidated state snapshot per frame.

When Simple Collection Fails

The most common failure mode we see in code reviews is using collect { } directly on a StateFlow that emits every input event. Because StateFlow conflates emissions, fast events can be lost—the renderer only sees the latest value, making smooth drawing impossible. Teams then switch to SharedFlow with replay=0 and extra buffer capacity, but forget to configure extraBufferCapacity properly, leading to dropped events when the renderer is slow. The fix is to use a Channel with onBufferOverflow = BufferOverflow.SUSPEND for input events, and a separate StateFlow for the current canvas state that the UI observes.

Foundations Readers Often Confuse

Even experienced Kotlin developers sometimes mix up the roles of StateFlow, SharedFlow, and Channel in a reactive canvas context. Let's clarify the essential differences and when each belongs in the pipeline.

StateFlow vs. SharedFlow vs. Channel

StateFlow is designed for observable state that has a single current value. In a canvas, the current set of strokes, the zoom level, or the active tool are good candidates. Observers always see the latest value immediately upon subscription, and updates are conflated—only the most recent value matters. But StateFlow is not suitable for event streams where every event must be processed, such as individual mouse move events during a stroke. If you use StateFlow for raw input, you will lose intermediate positions, resulting in jagged lines.

SharedFlow can replay a configurable number of past events and supports multiple collectors. It's useful for broadcasting events that multiple independent consumers need, like a log of canvas actions for undo/redo and for network sync simultaneously. However, SharedFlow still has a buffer that can overflow; you must configure extraBufferCapacity and onBufferOverflow carefully. Many teams assume SharedFlow is lossless by default, but it's not—if the buffer is full and you use SUSPEND, the emitter blocks, which may be acceptable; if you use DROP_OLDEST or DROP_LATEST, events are silently discarded.

Channel is the low-level primitive for communication between coroutines. It supports exactly one consumer by default (though fan-out is possible with BroadcastChannel or by using Channel with multiple receive calls). Channels are ideal for the input-to-renderer handoff because they naturally support backpressure via suspending sends. The capacity can be bounded, and you can choose overflow behavior. For a reactive canvas, a Channel<InputEvent> with capacity equal to the number of events you're willing to buffer (say, 64 or 128) and onBufferOverflow = SUSPEND provides a clean backpressure mechanism. The renderer coroutine consumes events in a batch per frame, allowing the input coroutine to suspend when the renderer is overloaded.

Structured Concurrency and Lifecycle

Another foundational point is that coroutines must be scoped to the canvas lifecycle. Using GlobalScope or forgetting to cancel coroutines when the canvas is destroyed leads to leaks and wasted resources. In Android, a ViewModelScope or lifecycleScope works well. For custom views, create a CoroutineScope with a SupervisorJob and cancel it in onDetachedFromWindow(). The input and renderer coroutines should be children of this scope, so that if one fails, the other is cancelled appropriately (or not, if you use SupervisorJob).

Patterns That Usually Work

After working through several production canvases, we've identified a handful of patterns that reliably produce smooth, maintainable results. These aren't one-size-fits-all, but they form a solid starting point for most applications.

Pattern 1: Channel-Based Input Pipeline with Frame Batching

This pattern separates input collection from rendering using a channel. The input coroutine (often running on the main thread or a dedicated input thread) sends each raw event into a channel. The renderer coroutine runs at the display refresh rate (e.g., 60 Hz) and, on each tick, drains all pending events from the channel into a batch, processes them (e.g., interpolating, culling off-screen points), and issues a single draw command. This approach ensures that the renderer never sees more events than it can handle per frame, and the input coroutine naturally slows down when the renderer is busy.

// Kotlin example (pseudocode)
val inputChannel = Channel<InputEvent>(capacity = 64, onBufferOverflow = BufferOverflow.SUSPEND)

scope.launch {
    // Input collector
    canvas.setOnTouchListener { event ->
        inputChannel.trySend(event) // use trySend to avoid blocking main thread
    }
}

scope.launch {
    // Renderer loop
    while (isActive) {
        val batch = mutableListOf<InputEvent>()
        // Non-blocking drain: collect up to N events
        inputChannel.receiveCatching().let { if (it.isSuccess) batch.add(it.getOrThrow()) }
        // Poll for more without suspending
        while (inputChannel.tryReceive().let { it.isSuccess && batch.size < MAX_BATCH }) {
            batch.add(it.getOrThrow())
        }
        // Process batch and render
        renderBatch(batch)
        // Wait for next frame (e.g., withChoreographer or delay)
        delay(16) // ~60 fps
    }
}

Pattern 2: StateFlow for Canvas State, Channel for Events

In this pattern, the canvas's persistent state—the list of completed strokes, the current tool, the zoom level—is held in a StateFlow. Input events are sent through a channel as above, but the renderer updates the StateFlow only when a stroke is finished (e.g., on pointer up). During a stroke, the renderer draws directly from the channel's batch without updating state, avoiding unnecessary recompositions. This keeps the UI layer (Compose or View) responsive because it only observes state changes when something meaningful happens, not on every pixel move.

Pattern 3: Flow Transformations for Gesture Recognition

Higher-level gestures (pan, pinch, long-press) can be derived from raw event flows using Flow operators. For example, you can create a flow from the channel using consumeAsFlow(), then apply windowed, map, and filter to detect patterns. This keeps the gesture logic declarative and testable. However, be cautious with operators that introduce concurrency, like flatMapMerge: they can reorder events if not configured with a concurrency limit. For gesture recognition, flatMapConcat is often safer.

Anti-Patterns and Why Teams Revert

Even with good intentions, teams often fall into traps that degrade performance or introduce subtle bugs. Here are the anti-patterns we see most frequently.

Overusing conflate() on Input Streams

The conflate() operator drops intermediate values when a new one arrives before the previous is consumed. On an input stream of mouse moves, this means the renderer only sees every Nth event, resulting in jagged lines. Teams sometimes use conflate() to reduce load on the renderer, but the visual quality suffers. The correct approach is to batch events without dropping them, as shown in Pattern 1.

Misplacing buffer() Without Understanding Backpressure

The buffer() operator adds a buffer between producer and consumer but does not inherently apply backpressure—it uses a default capacity and may drop events if the buffer overflows (depending on the BufferOverflow strategy). Many developers assume buffer() is lossless, but the default is SUSPEND, which can cause the producer to block unexpectedly. For input streams, it's better to use a Channel explicitly where you control the capacity and overflow behavior.

Collecting in a Composable or View Without Lifecycle Awareness

When using StateFlow in Jetpack Compose, collecting directly in a composable with collectAsState() is fine for UI state. But if you collect a flow that emits at high frequency (like raw input), the recomposition overhead can cause frame drops. The solution is to collect only the final state (e.g., completed strokes) in the composable, and handle raw input in a separate coroutine that interacts with the canvas directly.

Maintenance, Drift, and Long-Term Costs

Reactive canvas architectures that start clean often degrade over time as features are added. Understanding these long-term costs helps you design for maintainability from the start.

State Explosion and Undo/Redo Complexity

As the canvas supports more tools (pen, eraser, shape, text), the state model grows. If you store every stroke as an immutable object in a list, undo/redo becomes a matter of index manipulation. But if you mix transient state (current stroke) with persistent state, you need careful scoping. A common drift is to add mutable fields to stroke objects for performance, breaking immutability and making it hard to reason about state changes. We recommend keeping the state model immutable and using copy-on-write, even if it means some allocation overhead—it pays off in debuggability.

Concurrency Bugs from Shared Mutable State

When multiple coroutines access the same list of strokes or the same bitmap, race conditions emerge. For example, the renderer might read a stroke while the input coroutine is adding points to it. Using Mutex or atomic collections can help, but the cleaner solution is to design the pipeline so that each coroutine owns its data and communicates via channels or flows. The renderer should never mutate state that the input coroutine reads directly.

Performance Drift from Accumulated Operators

Flow chains that start simple can grow as new features are added: a map here, a filter there, a flatMapLatest for tool changes. Over time, the overhead of operator fusion and object allocation can become significant, especially on lower-end devices. Periodic profiling is essential. A useful practice is to document the expected throughput and latency budget for each stage of the pipeline, and revisit it when adding new operators.

When Not to Use This Approach

Not every canvas needs the full coroutine-and-Flow architecture. Here are scenarios where simpler alternatives are better.

Simple Drawing Apps with Low Event Rates

If your canvas only handles occasional taps or slow drags (e.g., a signature pad), the overhead of channels and batching is unnecessary. A simple StateFlow with collect in a view update loop works fine. The complexity of structured concurrency only pays off when event rates exceed roughly 60 events per second or when you have multiple concurrent input sources.

Single-Threaded Rendering with No Backpressure Need

If your rendering is fast enough to keep up with input on the same thread, you don't need a separate renderer coroutine. In such cases, processing input synchronously in the event callback is simpler and avoids the mental overhead of coroutine cancellation and channel management. However, be aware that any future performance improvements (like adding image loading or complex effects) will require refactoring.

Prototypes or MVPs

For a proof-of-concept, it's often faster to use a callback-based approach or a simple MutableStateFlow with collect. The advanced patterns add upfront complexity that may not be justified until you have validated the product direction. Just plan to rewrite the architecture before scaling to production.

Open Questions / FAQ

Over the years, we've fielded many questions about reactive canvas architecture. Here are answers to the most common ones.

Should I use one channel or multiple channels for different input types?

If input types have different priorities or processing paths (e.g., touch vs. keyboard shortcuts), separate channels can simplify the renderer logic. However, for a unified drawing surface, a single channel with a sealed class hierarchy for event types works well and avoids ordering issues between different input sources.

How do I handle undo/redo with a channel-based pipeline?

Undo/redo typically operates on the completed strokes state, not on the raw event stream. Store the list of strokes in a StateFlow and maintain an undo stack of snapshots or commands. The channel-based pipeline only handles the current stroke; when a stroke is finished, it's committed to the state. Undo then reverts the state to a previous snapshot, and the canvas redraws from the state.

What's the best way to test the pipeline?

Unit test each stage in isolation: test the gesture recognition flow with a TestCoroutineDispatcher and a TestChannel. For integration tests, run the full pipeline with a mock input source and verify that the renderer produces the expected output (e.g., a list of draw commands). Avoid testing with real frame timing; instead, advance virtual time manually.

Summary and Next Experiments

Reactive canvases are a demanding but rewarding use case for coroutines and Flow. The key takeaways are: separate input collection from rendering using channels with backpressure; use StateFlow only for persistent state, not for raw events; and design for maintainability by keeping state immutable and scoping coroutines to the canvas lifecycle. Start with the channel-based batching pattern, and only add complexity when profiling shows a need.

Next, try these experiments on your own projects:

  • Instrument your pipeline with metrics (event rate, frame time, buffer occupancy) to identify bottlenecks.
  • Replace a SharedFlow input stream with a channel and measure the difference in dropped events.
  • Implement a simple undo/redo system on top of your state flow and test it under rapid input.
  • Profile the overhead of Flow operators by comparing a channel-only pipeline with a flow-transformed one.
  • If you're using Compose, move the raw input collection out of the composable and into a ViewModel with a dedicated coroutine scope.

Share this article:

Comments (0)

No comments yet. Be the first to comment!