Skip to content

How much code does it take to build a REST API with concurrent request handling, in-memory storage, and JSON responses? In Wyn: 93 lines. 69KB binary. Zero dependencies.

The code

wyn
items = HashMap.new()
next_id = Shared.new(1)

fn main() -> int {
    server = Http.serve(8080)
    print("API on http://localhost:8080")
    while 1 == 1 {
        req = Http.accept(server)
        method = req.split_at("|", 0)
        path = req.split_at("|", 1)
        body = req.split_at("|", 2)
        fd = req.split_at("|", 3).to_int()

        if method == "GET" and path == "/api/items" {
            parts = []
            for k in items.keys() { parts.push(items.get(k)) }
            Http.respond(fd, 200, "application/json", "[${parts.join(",")}]")
        } else if method == "POST" and path == "/api/items" {
            id = Shared.add(next_id, 1)
            json = "{\"id\":${id},\"name\":\"${body.trim()}\"}"
            items.set("${id}", json)
            Http.respond(fd, 201, "application/json", json)
        } else {
            Http.respond(fd, 404, "application/json", "{\"error\":\"not found\"}")
        }
    }
    return 0
}

(Simplified from the full 93-line version which includes GET by ID, DELETE, and health check.)

What makes this possible

  • Http.serve / Http.accept - built into the stdlib, no framework needed
  • HashMap - built-in key-value store
  • Shared - a counter safe to touch from anywhere
  • "${expr}" - string interpolation builds JSON inline
  • No imports - everything is available out of the box

Why there is no spawn here

An earlier version of this post wrapped each request in spawn handle(...) and described it as "each request runs in a green thread". That was a mistake, and it is worth being specific about why, because it is the interesting part.

items is a plain HashMap, and a plain HashMap is not thread-safe. Two handlers writing it at once lose updates or corrupt it, depending on timing - so the old version usually worked, which is the dangerous kind of wrong. As of v1.21 the runtime says so out loud: concurrent mutation panics with concurrent HashMap mutation detected - use a channel or Shared to coordinate writers. Arrays and shared scalar globals already behaved that way; collections were the last gap, and it is closed.

So this version keeps all state on one thread. For an in-memory demo that is not a real cost: the loop is doing nothing but parse-and-respond, and it happily serves 40 concurrent clients on a laptop. When you outgrow it, the honest options are to send changes over a channel to one owning task, or to keep the shared state in something built for it - a database, or Shared for a counter, which is exactly what next_id already is.

The general rule, and it is the one thing to take away: decide where your mutable state lives before you add concurrency. Wyn will now tell you if you get it wrong, but it is a design decision, not a language feature.

Try it

bash
wyn new myapi --api
cd myapi
wyn run

The --api template generates a full CRUD API with SQLite persistence.

Comparison

WynGoNode.js
Lines93~120~80
Binary69KB6.2MBN/A (50MB runtime)
Dependencies01 (net/http)1+ (express)
Concurrencyspawn (thread pool)goroutinesasync/await

MIT License - v1.21.0