A bootcamp grad came into a session recently with the kind of panic I recognize immediately.
They had a Rust take-home due the next morning. The prompt was not huge: read a list of jobs from an API, normalize the results, spawn a small async task to fetch details for each job, and return a filtered summary. In JavaScript, they would have finished it in an afternoon.
In Rust, they had been stuck for three days.
By the time we met, the code technically had most of the right pieces: reqwest, tokio, a couple of structs with serde, and a filter function that made sense. But every attempt to make the async part clean turned into ownership and borrowing errors. They had started doing what a lot of smart beginners do under pressure: cloning strings everywhere, wrapping things in Arc<Mutex<_>> because a blog post mentioned it, then undoing half of that because the compiler got louder.
The first thing we did was not write code. We read the take-home prompt out loud and wrote down the actual data flow.
There were only three real stages:
- Fetch the list of job summaries.
- For each summary, fetch job details concurrently.
- Filter the completed records and print a small report.
That sounds obvious, but it mattered because their code was mixing all three stages together. One function was borrowing from the original API response, spawning tokio::spawn tasks, trying to push results into a shared vector, and also formatting output. The borrow checker was not being picky for no reason. It was pointing at a design where temporary borrowed data was being asked to survive inside independent async tasks.
The core compiler error looked like this: borrowed data escapes outside of function. The grad had read that sentence fifty times and still felt like Rust was being unfair.
So we translated it into plain English:
"This async task may run after the current function has moved on, so it cannot hold a reference to something owned by the current stack frame. If the task needs the value, give it an owned value."
That was the unlock.
Not "clone everything." Not "add lifetimes until it compiles." Not "put the whole world in a mutex." The task boundary was the ownership boundary.
We changed the job summary type so the fields needed by the spawned task were owned Strings, not borrowed &strs. Then, before tokio::spawn, we moved just the required values into the async block:
let handles = summaries.into_iter().map(|summary| {
let client = client.clone();
let job_id = summary.id;
tokio::spawn(async move {
fetch_job_details(&client, job_id).await
})
});
That one shape fixed more than half the errors. summary was consumed by into_iter(). The async move block owned job_id. The HTTP client was cheap to clone because it is designed for shared use. No borrowed response data was smuggled into a task that could outlive it.
Next we removed the shared Vec they had placed behind Arc<Mutex<Vec<_>>>. They had added it because they wanted every task to push its result somewhere. But for a take-home challenge, that made the solution harder to reason about and easier to deadlock or unwrap badly.
Instead, each task returned a Result<JobDetails, Error>, and the parent awaited the handles and collected the successful values. The data flow became one-way: spawn work, await work, collect work. Much simpler.
We also spent about fifteen minutes on error handling. Their original code had unwrap() in six places. Under deadline pressure that is understandable, but in a hiring exercise it sends the wrong signal. We did not build a perfect error hierarchy. We just used Result, ?, and a couple of helpful messages so a reviewer could see they were thinking about failure cases.
The final part of the session was explaining the code back. I asked them to narrate why async move was there, why the spawned task needed owned data, and why a mutex was unnecessary. The first attempt was shaky. The second was good. By the third pass, they were no longer saying "Rust hates me." They were saying, "The task needs to own what it uses because it can run independently."
That is the difference between randomly appeasing the compiler and actually learning Rust.
A take-home challenge does not require writing the most advanced Rust possible. It requires showing that you can make reasonable ownership decisions, structure async code clearly, and explain tradeoffs without panicking. In 90 minutes, we did not turn this grad into a Rust expert. We turned a tangled, deadline-stressed submission into something they could finish, test, and discuss confidently.
The practical lesson: when tokio::spawn enters the picture, stop and draw the ownership boundary. Anything the task uses must either be owned by that task, intentionally cloned into it, or shared through a type that is truly meant to be shared. Most beginner async Rust bugs come from skipping that step.
If you're in a similar spot, we do 90-min sessions starting at $49 — https://oxidementor.nanocorp.app
Top comments (0)