What multiplatform constraints apply when using kotlinx-coroutines-core and kotlinx-io in common code, especially around dispatchers and IO primitives across targets?
answer
- coroutine API is common; runtime differs per target
- Dispatchers.IO = JVM/Android only
- runBlocking unavailable on JS
- inject/expect-actual the dispatcher in commonMain
- kotlinx-io: Buffer/Source/Sink/ByteString replace java.io
basics
~10 sCoroutines work in common code, but some thread-based dispatchers and blocking calls only make sense on the JVM. kotlinx-io gives platform-neutral Buffer/Source/Sink so shared code can read and write bytes without java.io.
solid answer
~40 skotlinx-coroutines-core is fully multiplatform: `suspend`, `CoroutineScope`, `launch`/`async`, `Flow`, `StateFlow` all live in commonMain. But the **threading model differs**: `Dispatchers.Default` and `Dispatchers.Main` exist across targets, while `Dispatchers.IO` is **JVM/Android-only** (JS/Native are single-threaded event loops, so there is no thread pool to offload blocking IO to). `runBlocking` is unavailable on JS. Therefore common code should avoid blocking calls and not hardcode `Dispatchers.IO`; inject a dispatcher or use `withContext` with a `CoroutineDispatcher` provided per platform via `expect`/`actual`. **kotlinx-io** replaces `java.io`/Okio with platform-neutral primitives — `Buffer`, `Source`, `Sink`, and `ByteString` — so byte/streaming IO is expressed once in common code; platform integrations (files, sockets) come from `kotlinx-io-core` plus filesystem APIs that may be platform-specific. The pattern is: pure logic and IO shapes in common, blocking/thread-pool concerns isolated behind expect/actual or injected dispatchers.
code
kotlin · 8 lines// commonMain — dispatcher injected, no hardcoded Dispatchers.IO
class Repo(private val io: CoroutineDispatcher) {
suspend fun read(source: Source): String =
withContext(io) { source.buffered().readString() }
}
// jvmMain wires Dispatchers.IO; jsMain wires Dispatchers.Default
// expect val provider, or pass at construction (also testable)go deeper
Knows coroutines and kotlinx-io work in common code and that some java.io/JVM bits don't.
Identifies Dispatchers.IO as JVM-only and uses Buffer/Source/Sink instead of java.io.
Abstracts dispatchers via expect/actual or injection, knows runBlocking/JS limits, and structures common-vs-platform IO.
Designs the concurrency/IO architecture across all targets, plans testability with kotlinx-coroutines-test, and manages OS-handle boundaries and back-pressure consistently.
## Coroutines are common — but the runtime isn't uniform The coroutine **API** (`suspend` functions, `CoroutineScope`, `launch`, `async`, `withContext`, `Flow`, `StateFlow`, `SharedFlow`, `Channel`) is in kotlinx-coroutines-core and usable in `commonMain`. What differs is the **execution environment** of each target. ### Dispatcher differences - **`Dispatchers.Default`** — CPU-bound pool; available on all targets (on JS/Native it maps to the event loop / a worker model). - **`Dispatchers.Main`** — UI/main loop; available where there is one (Android main thread, etc.). - **`Dispatchers.IO`** — a large pool for **blocking** IO. **JVM/Android only.** Kotlin/JS and (single-threaded) Kotlin/Native have no such pool because they run on a single-threaded event loop, so there is nothing to offload blocking work to. - **`runBlocking`** — **not available on Kotlin/JS** (you cannot block the browser event loop); fine on JVM/Native. ### Consequence for common code Don't hardcode `Dispatchers.IO` in `commonMain` and don't call blocking APIs there. Instead: ```kotlin // commonMain expect val ioDispatcher: CoroutineDispatcher suspend fun loadConfig(path: String): String = withContext(ioDispatcher) { /* suspend-based read */ } ``` ```kotlin // jvmMain actual val ioDispatcher: CoroutineDispatcher = Dispatchers.IO // jsMain / native actual val ioDispatcher: CoroutineDispatcher = Dispatchers.Default ``` Or inject a `CoroutineDispatcher` as a constructor parameter (also better for testing with `StandardTestDispatcher`). ## kotlinx-io: platform-neutral bytes `java.io`/`java.nio` are JVM-only, and Okio is third-party. **kotlinx-io** is JetBrains' multiplatform byte-IO library: - **`Buffer`** — an in-memory, growable byte queue you write to one end and read from the other. - **`Source`** — readable byte stream (read primitives, UTF-8, etc.). - **`Sink`** — writable byte stream; `buffered()` wraps for efficiency. - **`ByteString`** — an immutable byte sequence. ```kotlin import kotlinx.io.* val buffer = Buffer() buffer.writeString("hello") val n = buffer.size val s = buffer.readString() // "hello" ``` These let you express parsing/encoding logic **once** in common code. Actual sources/sinks for files or sockets are obtained through platform-specific integrations (e.g. a `FileSystem` whose concrete instance is platform-provided), keeping the OS-specific part behind `expect`/`actual`. ## The unifying pattern 1. Keep **pure logic, coroutine orchestration, and IO shapes** in `commonMain`. 2. Isolate **blocking, thread-pool, and OS-handle concerns** behind `expect`/`actual` or injected dependencies. 3. **Never** assume `Dispatchers.IO` or `runBlocking` exist everywhere. ## Why it matters A shared module that hardcodes `Dispatchers.IO` compiles for JVM but fails to compile (or behaves wrongly) for JS/Native. Knowing which coroutine and IO facilities are truly common — and how to abstract the rest — is the core skill for writing robust shared code.
- Why is Dispatchers.IO absent on Kotlin/JS and single-threaded Native?Those targets run on a single-threaded event loop with no background thread pool, so there is nowhere to offload blocking IO; Dispatchers.IO would be meaningless. Use Default or platform-provided dispatchers.
- How do you keep common code testable given dispatcher differences?Inject the CoroutineDispatcher rather than referencing a global; tests pass StandardTestDispatcher/UnconfinedTestDispatcher from kotlinx-coroutines-test and use runTest.
Coroutines are a universal remote: the buttons (API) are identical everywhere, but the 'IO offload' button only does something on the device (JVM) that actually has extra channels.
saying these in an interview costs you the question
- Hardcoding Dispatchers.IO in commonMain
- Calling runBlocking and expecting it to work on JS
- Using java.io types in shared code
- Assuming blocking IO offload works on all targets
- Not isolating OS file/socket handles behind expect/actual