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
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 neededHashMap- built-in key-value storeShared- 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
wyn new myapi --api
cd myapi
wyn runThe --api template generates a full CRUD API with SQLite persistence.
Comparison
| Wyn | Go | Node.js | |
|---|---|---|---|
| Lines | 93 | ~120 | ~80 |
| Binary | 69KB | 6.2MB | N/A (50MB runtime) |
| Dependencies | 0 | 1 (net/http) | 1+ (express) |
| Concurrency | spawn (thread pool) | goroutines | async/await |
Related Posts
- I Built a Web Server Without Installing a Single Package - batteries-included web development
- Concurrent Port Scanner in 11 Lines - spawn/await in action
- Wyn v1.10 Release - real parallelism with spawn/await
Related Docs
- Build a Web Server - step-by-step tutorial
- Spawn & Await - concurrency reference
- JSON - JSON parsing and generation
- Wyn vs Go - how Wyn compares for web APIs
- Docker Deployment - containerize your API
- GitHub Actions CI - automate builds and deploys