1. The I/O wrapper pattern in a suspend function
When you write a program, it almost always feels like the main thing is “what it does.” But with coroutines, one more layer of reality appears: “where and how it does it.” File operations on the JVM are most often blocking: the thread literally waits for the disk, the OS, the file system, sometimes even the network (if the file is on a network drive). If you mix that with computations and don’t separate it cleanly, the program starts to “lag” in the most unexpected places—as if it suddenly lost the will to live.
Imagine a kitchen: Dispatchers.Default is the chefs who chop, fry, and think, while Dispatchers.IO is the courier standing in line for groceries. If you make the chefs stand in line instead of the courier, the kitchen stops. And yes, it sounds funny until you see how “just reading a file” breaks the responsiveness of the entire program.
A good I/O function in a coroutine app usually looks very boring—and that’s a compliment. It does the minimum: reads/writes/checks a file and returns a result. All the “smart” logic stays outside. Technically, that almost always means: suspend fun ... = withContext(Dispatchers.IO) { ... }.
The main idea: we explicitly mark the boundary where the blocking world of the file system begins. Thanks to this, you, your team lead, and even you a week later (the strictest reviewer) can quickly understand: “yep, this is where we touch the file system.”
Mini example: checking whether a file exists
This function seems too simple to be its own thing… but these are exactly the functions that later save a project from chaos, because they establish a consistent style.
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
suspend fun fileExists(path: String): Boolean =
withContext(Dispatchers.IO) {
File(path).exists()
}
Key point: even exists() is a call to the file system. Not always “heavy,” but blocking by nature.
Returning a value from withContext (and why it’s convenient)
withContext { ... } is an expression. It returns whatever the last statement in the block returns. This makes I/O functions “normal”: they’re easy to read, test, and use in your logic.
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
suspend fun readTextIo(path: String): String =
withContext(Dispatchers.IO) {
File(path).readText()
}
There’s a nice detail here: readText() opens/closes the resource internally, so we don’t need use { ... }. But as soon as you move to streams/readers/writers, use becomes your best friend.
Early exit: return@withContext
When inside withContext you want to “return null” or “finish conditionally,” a regular return won’t work (it will try to exit the whole function). You need a labeled return: return@withContext.
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
suspend fun readFirstLineOrNull(path: String): String? =
withContext(Dispatchers.IO) {
val file = File(path)
if (!file.exists() || !file.isFile) return@withContext null
file.bufferedReader().readLine()
}
Here we intentionally made the example “without use” to see the problem: bufferedReader() must be closed. We’ll fix it in the next section.
3. use { ... }: guaranteed resource closing
In file I/O there’s an old truth: “Opening a stream is easy. Closing a stream on time takes character.” In coroutines, character matters even more, because execution can be interrupted by exceptions, cancellation, early returns, and plain bugs.
use { ... } is a Kotlin idiom that guarantees closing a resource (roughly like try/finally, just more compact and less prone to copy-paste). On the JVM it works for Closeable/AutoCloseable: readers, writers, streams.
Correctly reading the first line: bufferedReader().use { ... }
Now let’s rewrite the previous example so it doesn’t leave an open resource behind.
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.io.use
import java.io.File
suspend fun readFirstLineOrNull(path: String): String? =
withContext(Dispatchers.IO) {
val file = File(path)
if (!file.exists() || !file.isFile) return@withContext null
file.bufferedReader().use { reader ->
reader.readLine()
}
}
What matters here: use closes reader even if readLine() throws an exception. This is that rare case where Kotlin “magic” is really just a careful finally.
Writing text via bufferedWriter().use { ... }
Writing should be careful too. writeText() and appendText() are convenient, but sometimes you want to write line by line, or control line breaks, or you’re simply used to Writer.
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.io.use
import java.io.File
suspend fun writeLinesIo(path: String, lines: List<String>) {
withContext(Dispatchers.IO) {
val file = File(path)
file.parentFile?.mkdirs()
file.bufferedWriter().use { w ->
for (line in lines) w.appendLine(line)
}
}
}
Notice parentFile?.mkdirs(): it’s a small detail that turns a program from “falls over for no reason” into “works predictably.”
Nested use: is that okay?
Yes, it’s fine. Sometimes it looks like nesting dolls, but it’s honest nesting: the outer resource will close and the inner one will close too. For example, if you want to “read the first line of one file and append it to another.”
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.io.use
import java.io.File
suspend fun copyFirstLine(from: String, to: String) {
withContext(Dispatchers.IO) {
File(from).bufferedReader().use { r ->
File(to).bufferedWriter().use { w ->
w.appendLine(r.readLine() ?: "")
}
}
}
}
This example is intentionally simple. Larger “chunked” copies and cancelability will be done in later lectures so we don’t mix topics.
4. Separate I/O from processing
The most common beginner mistake (and sometimes even an experienced developer who “just wanted to do it quickly”) is to do “everything inside withContext(Dispatchers.IO)”: read a file, parse it, compute statistics, sort, format a report, write the report… and it seems to work. But it’s bad architecture: you lose control over what’s I/O vs. CPU, and you start accidentally running computations in the IO pool.
A better mental model: an I/O function should touch the file system, but it shouldn’t be the “kitchen for the whole dish.” It brings the groceries; the chef cooks.
Bad example: “everything in one place”
Here we intentionally make the example “wrong” so you can see the problem: we mixed file reading with heavy processing.
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
suspend fun countLongLinesWrong(path: String): Int =
withContext(Dispatchers.IO) {
val lines = File(path).readLines()
lines.count { it.trim().length >= 80 }
}
Formally, it works. But now CPU work (trim(), length, count) lives in the IO context. Sometimes that’s minor; sometimes it’s the start of major slowdowns.
Good example: “read first, then count”
Let’s split into two layers: the I/O layer returns data, the domain layer counts.
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
suspend fun readLinesIo(path: String): List<String> =
withContext(Dispatchers.IO) { File(path).readLines() }
suspend fun countLongLines(path: String): Int {
val lines = readLinesIo(path)
return lines.count { it.trim().length >= 80 }
}
Yes, it got a couple of lines longer. But now you have a clear separation: readLinesIo is about files, countLongLines is about logic. If later you want to optimize processing, cache, or change the data source, you won’t be digging it out of a “monolith.”
Mini table: a quick “eyeball check”
After this section, it’s handy to have a short self-check rule:
| Question about the function | If the answer is “yes” | What it means |
|---|---|---|
| Does it touch File, Reader, Writer, InputStream, OutputStream? | Yes | Inside, you should have Dispatchers.IO and careful resource closing |
| Does it sort, filter, parse, compute statistics? | Yes | This is domain/CPU logic; try to keep it outside Dispatchers.IO |
| Does it do both | Yes | Then you mixed responsibilities, and the code will be harder to maintain |
The table is simple, but in a real project it saves hours of “why is it slow.”
5. Practice: File Organizer and a careful I/O layer
So the examples aren’t “in a vacuum,” let’s imagine we already have a console app called File Organizer from previous mini-projects: it walks a directory, selects files, and sorts them into folders. Today we won’t parallelize and we won’t do cancellation—our goal is simpler: build clean I/O utilities that are convenient to use later.
Let’s say we have two helper files: organizer.ignore (a list of extensions to ignore) and organizer.log (an event log).
Read the ignore list as a Set<String>
Let’s write a function that reads lines, trims whitespace, and normalizes them. Important: keep the file part in Dispatchers.IO, and you can do the “normalization” outside—but here it’s lightweight, so we’ll do it carefully while keeping things readable.
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
suspend fun readIgnoreExtensions(path: String): Set<String> =
withContext(Dispatchers.IO) {
val file = File(path)
if (!file.exists()) return@withContext emptySet()
file.readLines()
.map { it.trim().lowercase() }
.filter { it.isNotEmpty() && !it.startsWith("#") }
.toSet()
}
Here we allowed a bit of processing inside IO, because it literally “tidies up” the input strings and doesn’t turn the function into a combine harvester. If there were heavy statistics, sorts over tens of thousands of lines, etc., we’d move it outside.
Logging: appendLine as a separate I/O operation
Logs are classic: you write one appendText(...) in the middle of the program, then a second, then a third… and suddenly logging is smeared across the project. Better to make one small I/O utility.
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
suspend fun appendLogLine(path: String, line: String) {
withContext(Dispatchers.IO) {
val file = File(path)
file.parentFile?.mkdirs()
file.appendText(line + "\n")
}
}
Yes, it’s “just two operations.” But this is exactly how you build projects that don’t fall apart: small, understandable bricks instead of “I’ll just do it quickly here.”
Using it in main: runBlocking only at the entry point
A very important discipline: runBlocking lives in main, not in helper functions. Helper functions should be suspend.
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val ignore = readIgnoreExtensions("config/organizer.ignore")
println("Ignore: $ignore") // Ignore: [...]
appendLogLine("logs/organizer.log", "Organizer started")
println("Log written") // Log written
}
This kind of main is easy to extend: later you’ll add file traversal, parallel processing, and reports without breaking the basic architecture.
Where to put withContext, and where not to
When you’re just starting out, you want to find a “magic rule,” like “wrap the whole function in withContext(IO).” But it’s better to think slightly differently: withContext(Dispatchers.IO) goes around the exact lines of code that touch the file system. Not “around the function,” not “around the module,” but around a specific I/O block.
A normal control-flow scheme
To lock the picture into your head, it’s useful to imagine it like this:
flowchart TD
A[main/runBlocking] --> B[Scenario/logic]
B --> C["withContext(Dispatchers.IO)"]
C --> D[File API / Streams / Readers]
D --> C
C --> B
B --> A
The point of the scheme: the logic calls an I/O brick; the I/O brick “dives” into the IO dispatcher, does the work, and returns the result back to the logic.
Why you shouldn’t keep withContext(IO) “too wide”
If you write it as “the entire organize command runs in the IO context,” you deprive yourself of the ability to clearly separate computations from file work. Sometimes this will lead to the CPU part ending up in the IO pool (which is actually meant for waiting on I/O), and performance becomes unpredictable.
Roughly speaking: a wide withContext(IO) turns the IO context into a trash bin “for everything I don’t know where to put.” And we’re learning to do the opposite: put things on the right shelves.
6. Common mistakes: Dispatchers.IO and use { ... }
Error #1: calling runBlocking inside a helper function “because it’s easier to call suspend that way.”
It looks like a lifehack, but in practice you’re putting a stick in your own wheel: you block a thread, break structured concurrency, and get weird freezes in unexpected places. If a function does I/O, let it be suspend, and keep the coroutine-world entry point in main.
Error #2: opening bufferedReader()/inputStream() and forgetting to close it because “it’s small.”
Small today doesn’t mean small tomorrow. File descriptor leaks accumulate, and at some point the OS will say “enough” (usually in the worst possible way). use { ... } is not decoration; it’s insurance: the resource will be closed in any outcome, including exceptions.
Error #3: stuffing heavy processing inside withContext(Dispatchers.IO) because “it’s all about the file anyway.”
The file is a data source, but sorting, aggregations, and complex parsing are CPU work. If you keep that in the IO context, you get code that is hard to optimize and hard to analyze. Separate it: read in IO, process outside.
Error #4: turning an I/O function into a “universal combine harvester” that reads, validates, prints, and logs.
Such code quickly becomes unreadable: it has ten reasons to change, and each new feature adds yet another branch. It’s much calmer to have a few small functions: readLinesIo, appendLogLine, writeLinesIo. Yes, there are more of them. But each one is understandable and predictable.
Error #5: forgetting about return@withContext and trying to use a regular return inside the block.
This is a common “first hit from the compiler” in coroutines. If you need to exit early inside withContext, use return@withContext. It’s not Kotlin being picky; it’s a way to make control flow explicit and safe.
Error #6: mixing directory preparation (mkdirs) and writing in a way that leaves a half-working state on error.
Even in simple functions, it’s helpful to follow discipline: first create the directory (if needed), then open the writer in use, then write. This reduces the chance of leaving the system in a weird intermediate state, especially when later you add cancellation and parallelism.
GO TO FULL VERSION