A Gopher Meets a Crab
I’ve been writing Go for about a decade, and for most of that time Rust has been the language I respected from a polite distance. I bought the book, thumbed through it, but mostly it sat on the shelf. I gave rustlings a try once or twice but always ended up wandering away. I never had the hook to really motivate me to dig in. Miren is at the inaugural TokioConf this week (I’m writing this from the plane there), and my desire to cook up a decent demo presented the perfect opportunity.
So I built a chat server. Well, okay, I told Claude to build a chat server, and then spent a long time asking Claude about what the heck it just wrote. Treated it like my Rustacean pair I could bug to explain things to me. Not nearly as good as a human, but patient, and available over airplane wifi. It’s in our repo full of sample apps, running live at chat.miren.toys. Swing by this week and you might find a few friendly strangers already typing.

Notes from a gopher
So now I’ve spent some honest hours trying to fit Rust into a Go-shaped brain. Here’s what fit nicely and what popped back out the other side.
Exhaustive enums are the thing I’ve wanted in Go for years. In Go, I either reach for codegen or write a test case any time I want to prove I handle every variant of a type. Rust’s compiler just checks. Add another variant tomorrow and every match in the codebase that doesn’t cover the new case lights up red at compile time. I can stop writing tests for my own forgetfulness.
The ? operator is kind of a revelation to an if err != nil-addled brain like mine. One character for what used to be a block. Though I’ll admit, the number of implicit exits from a function still makes me a little itchy. It’s an interesting spot where the two languages diverge on what’s worth making explicit. Go wants you to see every return path, even the sad ones. Rust trusts the type system to carry it instead.
The first line of Rust that made me laugh out loud:
match tokio::time::timeout(Duration::from_secs(2), receiver.next()).await {
Ok(Some(Ok(Message::Text(t)))) => { /* happy path */ }
_ => { /* anything else */ }
}
Reads like a nervous little parser muttering to itself. “Ok… some… ok… message… text!” Each layer hoping the next one holds. My reaction to Claude was “do rust-heads not blink at a line like that?” Answer: yes, a little. This is on the border of “clever” and “just write it flat.”
We refactored to lean on ? instead of the big match clause, which gets you to a slightly cleaner helper:
let msg = tokio::time::timeout(Duration::from_secs(2), receiver.next())
.await
.ok()?
?
.ok()?;
That unfortunately just swaps out the nervous parser for a slightly deaf one. “Ok?” ”…?” “Ok?” Three tries at the answer, cupping its ear a bit more each time. It still reads funky to me, but to be fair it’s doing the work of about five nested if err != nil branches in Go, one of which I would absolutely forget.
The first line that made me swear:
async fn send_json<S>(sender: &mut S, msg: &ServerMessage) -> Result<(), ()>
where
S: SinkExt<Message> + Unpin,
S::Error: std::fmt::Debug,
I stared at these Curry-Howard crimes and typed a series of expletives at Claude that were in an original draft of this post but we decided to elide to preserve a sliver of corporate dignity. Claude patiently walked me through it, and it made sense piece-by-piece but absolutely did not stick. Please do not ask me to explain that snippet of code to you. At the end, Claude conceded that for an app, we could just write the concrete type here. So I backed away slowly. Go generics can get mind-bending pretty quickly too, but maybe not as quickly.
The runtime in plain sight
Rust’s async runtime isn’t part of Rust. There’s no go keyword, no built-in scheduler, no goroutines. The language gives you the syntax for async (async fn, .await, futures as types) and then tells you to go find a runtime that will actually poll them. Tokio is one of those runtimes. You import it into your app.
In Go, that whole apparatus is the language. You type go func() and the scheduler takes it from there. Goroutines, GC, stack growth, preemption: all of it lives under a carpet you can’t really lift.
Rust yanks the carpet. The runtime doesn’t hide its shape from me; the language insists I spell more of it out. That’s what Tokio is: not a feature bundled into the language, but a library I imported, with primitives I can watch and poke at directly.
It took me a while to realize this is what I’d been reading as verbosity. Go is verbose at the surface and quiet underneath. Rust is dense at the surface and quiet nowhere. I did not expect to come home thinking of Go as the more implicit language. But here we are.
Now watch this
That’s where tokio-console comes in. Go has pprof and it’s great; tokio-console live-tails the runtime rather than snapshotting it, so every task, channel, and mutex is a row you watch tick in real time.
Wiring it in took three lines of Rust and a service-port declaration in .miren/app.toml:
[[services.web.ports]]
port = 6669
name = "console"
type = "tcp"
node_port = 30669
Then, from the plane:
$ tokio-console http://chat.miren.toys:30669

Three humans in the chat room showed up as three trios of tasks: an extract, a send, a receive per client. The axum accept loop had spent 29 microseconds doing work across ten minutes of life (0.000005%), which is what async looks like when it’s working. And my code showed up in the console as naturally as Tokio’s own internals.
Not yet
I had fun finally learning some real Rust, but this old gopher hasn’t suddenly transformed into a crab. The trade-offs Go makes serve the kind of work I do most days. But I hear that over a long enough time span, chances are, it’ll happen.