skip to Main Content

Tracing the User Journey with Coroutines in Kotlin Multiplatform

January 11, 20264 minute read

  

How do you understand what a user actually experienced when everything is asynchronous, cross-platform, and happening “somewhere” inside coroutines?

Kotlin Multiplatform (KMP) gives us a powerful shared runtime across Android and iOS. Coroutines make concurrency expressive and safe.

But once your app grows beyond a few screens, observability becomes hard.

Not logging!

Not analytics!

It’s tracing.

This post explores how we can trace a user journey end-to-end across coroutine boundaries in KMP — without polluting every function signature or breaking structured concurrency.

The Problem: Asynchrony Breaks the Narrative

Consider a real user journey:

  1. User opens the app
  2. Authentication refresh kicks in
  3. Tenant configuration is fetched
  4. Profile data loads
  5. A background sync starts
  6. User navigates to another screen

From the user’s perspective, this is one journey.

From the runtime’s perspective?

  • Multiple coroutines
  • Different dispatchers
  • Nested launches
  • Platform-specific threading
  • Suspensions and resumptions
  • Possibly even multiple scopes

Traditional logging gives you events.

Analytics gives you aggregates.

What’s missing is causality.

“This network call happened because the user tapped this button in this session.”

Why Not Just Pass a traceId ?

The obvious solution is:

fun loadProfile(traceId: String) { ... }

And then pass it everywhere.

This fails quickly:

  • Bloated APIs
  • Easy to forget
  • Hard to enforce
  • Breaks composability
  • Feels un-Kotlin-like

Coroutines already have a solution to this problem.

Coroutine Context: The Hidden Superpower

Every coroutine runs with a CoroutineContext.

You already use it:

  • Dispatchers.IO
  • Job
  • CoroutineName

But the key insight is:

CoroutineContext is automatically propagated across suspensions.

That makes it perfect for trace propagation.

Modeling a Trace

At minimum, a trace needs:

  • A stable traceId
  • A logical flow name (e.g. LoginFlow, HomeStartup)
  • Optional metadata for debugging
data class TraceInfo(
val traceId: String,
val flow: String,
val launchStack: String? = null
)

This object represents “why this coroutine exists.”

Attaching Trace to Coroutines

We introduce a custom CoroutineContext.Element:

class TraceElement(
val traceInfo: TraceInfo
) : CoroutineContext.Element {

companion object Key : CoroutineContext.Key<TraceElement>

override val key: CoroutineContext.Key<*> = Key

}

Now any coroutine can carry trace information implicitly.

Platform-Safe Access with

expect / actual

In KMP, we need a way to read the current trace from anywhere — networking, logging, performance tracking.

We define a common API:

// commonMain
expect object TraceLocal {
fun get(): TraceInfo?
fun set(value: TraceInfo?)
}

Android (actual)

Uses ThreadLocal safely because coroutines restore it on resume.

iOS (actual)

Uses a coroutine-aware storage (no reliance on thread affinity).

This abstraction is critical:

business code stays 100% common.

Launching a Traced Coroutine

We wrap coroutine launching with a helper:

fun CoroutineScope.launchTraced(
flow: String,
context: CoroutineContext = EmptyCoroutineContext,
block: suspend CoroutineScope.() -> Unit
): Job {
val trace = TraceInfo(
traceId = generateTraceId(),
flow = flow,
launchStack = Throwable().stackTraceToString()
)

return launch(
TraceElement(trace) + CoroutineName(flow) + context,
start = CoroutineStart.DEFAULT
) {
val previous = TraceLocal.get()
TraceLocal.set(trace)

// Log the start of the flow with stack trace
Logger.i { "[flow-start]n${trace.launchStack}" }

try {
block()
// Log the end of the flow
Logger.i { "[flow-end]" }
} catch (t: Throwable) {
// Log any error in the flow
Logger.e(t) { "[flow-error]" }
throw t
} finally {
// Restore previous trace context
TraceLocal.set(previous)
}
}
}

From this point onward:

  • Every child coroutine inherits the trace
  • Every suspend/resume keeps the trace
  • No function signatures are touched

Using the Trace Anywhere

Logging

val trace = TraceLocal.get()
logger.info(
"Fetching profile",
mapOf(
"traceId" to trace?.traceId,
"flow" to trace?.flow
)
)

Networking (Ktor)

headers {
TraceLocal.get()?.let {
append("X-Trace-Id", it.traceId)
append("X-Flow", it.flow)
}
}

Performance Measurement

val trace = TraceLocal.get()
performanceTracker.mark(
name = "ProfileApiCall",
traceId = trace?.traceId
)

Now logs, network calls, and metrics all speak the same language.

Why This Scales

This approach works because it:

  • Respects structured concurrency
  • Requires zero boilerplate in leaf functions
  • Works across Android & iOS
  • Survives dispatcher switches
  • Makes causality explicit
  • Is testable (trace can be injected)

Most importantly:

Tracing becomes an infrastructure concern, not an application concern.

Debugging User-Reported Issues

When a user reports:

“App was stuck on loading after login”

You can now reconstruct:

  • Which flow it was
  • Which calls happened
  • Which coroutine launched what
  • Where cancellation or delay occurred

All from a single traceId.

Final Thoughts

Coroutines are not just about concurrency.

They are about context.

By embracing CoroutineContext as a carrier of user intent, we can finally trace user journeys in a way that matches how modern apps actually work.

If you’re building serious KMP applications — especially with shared networking, auth, and configuration layers — this pattern pays for itself very quickly.

If you’re interested in Kotlin Multiplatform, Flutter or Scalable Mobile Architecture, let’s connect on LinkedIn.


Tracing the User Journey with Coroutines in Kotlin Multiplatform was originally published in ProAndroidDev on Medium, where people are continuing the conversation by highlighting and responding to this story.

 

Web Developer, Web Design, Web Builder, Project Manager, Business Analyst, .Net Developer

No Comments

This Post Has 0 Comments

Leave a Reply

Back To Top