All posts

June 8, 2026

Kestrel 0.17

Kestrel 0.17

Kestrel 0.17 is here.

Kestrel is a static-typed, compiled language built for a great developer experience, low memory usage, and the age of AI. New to Kestrel? Start with Introducing Kestrel for the full tour — this post covers what changed in 0.17.

Every release has a theme, and 0.17's is simple: growing up. The last few releases were about making Kestrel pleasant to write. This one is about making it fast, sound, and deployable — a new optimizing backend that closes a 45× throughput gap with Go and Node, a references feature that brings zero-copy APIs without lifetime annotations, and the largest bug hunt in the project's history, closing nearly 80 reported issues against the compiler.

Here's the story.

New in 0.17

References (Preview)

Until now, the only way for a function to give you a value it owned was to copy it out. That's fine for an Int64; it's not fine for the string sitting inside a struct or the element in the middle of an array. What was missing is the ability to lend a value — hand out direct access without copying and without giving up ownership.

0.17 adds references, and they come with a deliberate design constraint: no lifetime parameters, no annotations, ever. Kestrel references are second-class — they name a place, they're created in a few well-defined positions, and the compiler tracks where every reference comes from and rejects any use that could dangle. If your code compiles, no reference in it dangles. You get the plain diagnostic, not the lifetime puzzle.

Taking a reference looks like this:

var x: Int64 = 10; let r = &x; // shared reference: read-only view of x let m = &mutating x; // mutable reference: requires x to be a var m += 5; // writes through the reference — x is now 15

Where it gets interesting is APIs. Methods can return references, lending out a field or an element directly:

struct Box { var value: Int64; func peek() -> &Int64 { // lend read access self.value } mutating func slot() -> &mutating Int64 { // lend write access self.value } } var b = Box(value: 41); b.slot() += 1; // read-modify-write in place — no copy, no writeback dance

Subscripts and computed properties get the same power through ref accessors — a subscript can lend the element's actual storage instead of copying it in and out:

subscript(at index: Int64) -> Int64 { ref { self.storage.offset(by: index).value } mutating ref { self.storage.offset(by: index).mutatingValue } }

And to keep everyday code pleasant, references decay: in any position that wants an owned value, a reference quietly copies its target out. let copied = b.peek() gives you an owned Int64; holding an actual reference requires the explicit &. The common case reads like normal code, and the zero-copy case is there when you want it.

References are shipping as a preview in 0.17 — the core model (borrows, ref returns, ref accessors, references in structs and tuples, Optional[&T]) is in and tested, and the design is settled on the big question: there will never be lifetime annotations in Kestrel.

A Real Entry Point: @main

Kestrel programs used to start at whatever function happened to be named main. That convention breaks down quickly — it can't express "this library has no entry point," and it gives the compiler no place to hang rules about what an entry point is allowed to look like. 0.17 replaces the naming convention with an attribute:

@main func main() { println("Hello!"); }

The attribute, not the name, is what makes it the entry point. An executable build requires exactly one @main; libraries need none; and the compiler now has clear diagnostics for every way to get it wrong (two entry points, @main on a method, and so on).

Custom Main Return Types

Making the entry point explicit unlocked the second half: main can now say how the process exits, through an ordinary protocol called Exitable. Return () for a plain success, an ExitCode or any integer type for an explicit status — or declare main as throwing and let errors handle themselves:

@main func main() -> () throws AppError { let config = try loadConfig(); run(config); }

If main throws, the error prints itself to stderr and the process exits 1 — no boilerplate match at the top level. And because Exitable is just a protocol, your own status type can conform and be returned from main directly. The old codegen special-casing for main is gone; the entry point is now ordinary code all the way down.

The LLVM Backend

This is the change the whole Performance section below hangs off of. Kestrel has compiled through Cranelift since day one — it's fast to compile and great for development, but it doesn't inline, and in a language where every integer operation is a stdlib function call, that left an order of magnitude on the table.

0.17 adds a second backend: LLVM, with real optimization levels.

kestrel build main.ks --backend llvm -O 2

Cranelift remains the default, and that's deliberate — the two backends now cover the two things you actually want. Development builds stay on Cranelift for compile speed; deployment builds go through LLVM -O2 for runtime speed. The numbers below tell the rest of this story, but the short version: inlining alone is worth roughly 10× on real server workloads, and the optimized binaries come out smaller — 332 KB for a full web app.

flock install

Small feature, big quality-of-life win: the package manager can now install command-line tools.

flock install kestrellang/kestrel-doc

flock install builds a package's binaries and drops them into ~/.flock/bin — from the registry by org/package (with optional @version), or from the current package while you're developing it. Put ~/.flock/bin on your PATH once and every Kestrel CLI tool is an install away. It's the missing piece that makes writing tools in Kestrel practical, not just apps.

Performance

Now for the payoff. In 0.16, Kestrel was honest about being slow: an order of magnitude behind Go and Node on web workloads. In 0.17, that gap is gone.

The benchmark is the same one we've used all along: identical sticky-note wall apps written in Kestrel, Node, and Go, serving real pages from a real SQLite database with 156,000 rows.

Serving pages

45× faster than 0.16. Kestrel now serves pages right alongside Go and Node — within a few percent of both. Most of the win comes from the new LLVM backend, with the rest from a faster HTML renderer in the standard library.

Writing data

Every one of these requests is a real database write. 5.4× faster than 0.16, ahead of Node, and neck-and-neck with Go.

Deploy size

An unexpected bonus: the optimized build isn't just faster, it's 7× smaller. The entire web app — server, HTML rendering, database driver — deploys as a single 332 KB binary. That's 37× smaller than the Go equivalent, and 50× smaller than Node's runtime and dependencies.

Memory

The thing Kestrel was already best at didn't change. No runtime, no garbage collector: ~3 MB under load and 8 ms startup — at 45× the throughput of 0.16.

And these numbers actually undersell it: Kestrel serves all of that traffic on a single CPU core, while Go spreads across two and a half. Measured per core, Kestrel is now the most efficient of the three.

Number crunching too

Web serving is one kind of fast. Tight computational loops — simulations, image processing, games — are another, and 0.17 improved there as well, using Conway's Game of Life as the stress test:

An optimized Kestrel build now runs the Life simulation at roughly 60% of the speed of hand-written C — and more than 10× faster than Go or Node — because Kestrel's new backend can automatically turn simple loops into vectorized machine code. (Full disclosure: reaching that number today takes one manual tweak to the hot loop; making it fully automatic is next on the list.)

On a real $5 server

Benchmarks on a fast dev machine are one thing. So we deployed the same app — the Kestrel, Go, and Node versions side by side — to the cheapest server DigitalOcean sells: one CPU, less than half a gigabyte of RAM.

All three handled the traffic. The difference is what they cost to keep running:

Kestrel 0.17GoNode
Memory at rest4.9 MB91.9 MB74.9 MB
Deploy size340 KB, no runtime12 MB binary120 MB runtime + deps
Page render~0.65 ms~1.0 ms~3.0 ms

The same app's memory footprint, to scale: the kestrel perches on a single small server while the gopher stares up at its towering stack

Kestrel runs the same app in 15–19× less memory than Go or Node, renders pages fastest, stayed rock-solid under a sustained load test with zero errors, and its memory never crept up. On a box that small, that headroom is the whole game.

One practical note for anyone deploying: these gains come from the optimized LLVM build, so build with --backend llvm -O 2 for production. The default backend is tuned for fast compiles during development, not fast binaries.

Bug Fixes

Speed means nothing if the compiler miscompiles your code, so the third thread of 0.17 was a systematic bug hunt: throw real programs, adversarial edge cases, and fuzzing at the compiler, file everything, then fix cluster by cluster until the list is empty. The result is the largest batch of bug fixes in Kestrel's history — nearly 80 reported issues closed.

The hunt reached every corner of the compiler, but a few themes stand out:

  • The memory model got sound. Kestrel's ownership model — moves, copies, drops, no GC — is the foundation everything sits on, and the hunt found real cracks: use-after-move that compiled silently, values double-dropped or never dropped, fields drop-ordered wrong. All of it is fixed, and moving a value into a struct literal, across a loop, or out of a consuming self now does exactly what the model says it should.
  • Closures stopped surprising. return inside a closure returned from the closure (not the enclosing function), escaping closures that would dangle are rejected at compile time, and a family of capture and dispatch bugs is gone.
  • Numerics are now exact. Float printing is shortest-round-trip by default, decimal-to-float parsing is correctly rounded, checked arithmetic uses real overflow intrinsics, out-of-range integer literals are a hard error instead of silently wrapping, and every division/remainder/shift edge case behaves identically on both backends.
  • Silent wrongness became loud. Several bugs in this batch were the worst kind — code that compiled and ran but did the wrong thing: assignments that were no-ops, a projection that returned the whole struct, a failable init that returned stale data. Those are all fixed, and as a matter of policy, any internal codegen failure now fails the build instead of emitting a binary that traps.

The full list, organized by area, is at the bottom of this post.

Install

Install or update through Jessup, the toolchain manager:

jessup install preview jessup default preview

Full List of Bug Fixes

Memory model & move checking

  • #125 — returning a string literal selected by a match could alias and corrupt the result
  • #127 — non-Copyable array literals double-deinited their elements
  • #141 — move-out + reinit of self in mutating methods (e.g. Optional.take) was unsound
  • #142Array of a non-Copyable type is now rejected cleanly
  • #145, #152 — moving a non-Copyable field out of consuming self was falsely rejected and double-dropped
  • #154 — reassigning a field in an init body never dropped the old value
  • #155Array.clear() didn't run element deinits
  • #162 — moves into struct/enum/tuple/array literals weren't tracked, so use-after-move compiled
  • #163 — use-after-move across a loop back edge wasn't diagnosed
  • #164 — reading a Copyable field through a non-Copyable tuple element was falsely rejected
  • #180 — moving a Cloneable payload out of an owned enum deep-cloned instead of moving
  • #181 — struct and enum fields now drop in reverse declaration order
  • #204try propagation double-deinited a non-Copyable error payload

Closures

  • #173, #178 — an it-closure poisoned the arity of sibling zero-parameter closures; closure parameter conventions now come from let annotations
  • #174 — a closure escaping its captured locals produced a dangling environment; now rejected at compile time
  • #175, #176 — immediately calling a closure returned from a method or subscript dispatched to the wrong function
  • #177 — use-after-capture of a non-Copyable value crashed the compiler; moving a captured value out of a closure is now a proper error
  • #199return inside a closure was treated as returning from the enclosing function

References (the new preview feature)

  • #192, #193, #194, #195, #196 — false escape errors on -> &mutating returns, a reference-returning witness ABI miscompile, and missing reference decay in assignment, return, and closure-tail positions

Pattern matching & control flow

  • #121, #126 — compiler crashes on multi-binding guard/if let/while let and on match guards that bind
  • #122 — exhaustive tuple-literal matches crashed the compiler
  • #186 — open-ended range patterns never matched
  • #187or-patterns with bindings crashed the compiler
  • #188 — array patterns now work at runtime, including custom ArrayMatchable conformers
  • #189, #190 — enum payload sub-patterns falsely reported E305, and a wildcard after a payload split crashed the compiler
  • #201 — labeled continue crossing an inner loop crashed the compiler
  • #202 — a Never-typed call didn't count as diverging in a guard else block
  • #203try in an unparenthesized if/while condition failed to parse

Protocols & generics

  • #146, #147, #150 — static protocol requirements: static functions, initializers, and stored/computed static vars now dispatch correctly through witnesses
  • #148 — default-argument expressions calling statics on a type parameter
  • #165 — conformances on disjoint specializations (Box[Int64] vs Box[Bool]) were falsely flagged as duplicates
  • #167, #168some P in a stored field is now a clean error instead of a compiler crash
  • #182 — overlapping generic conformances now pick the most specific one
  • #183 — returning some P from a generic struct's method crashed at monomorphization
  • #184, #185 — associated-type bounds like where T.Item: Show now work
  • #213 — a constrained protocol-extension method wasn't recognized as a witness
  • #214 — projecting a field off a static var silently dropped the projection and returned the whole struct
  • #215 — unit () and Never can now conform to protocols

Numerics & floats

  • #156 — signed minValue formatted incorrectly
  • #157 — float comparisons now use IEEE 754 semantics directly
  • #158, #159, #206 — division, remainder, and shift edge cases are now deterministic and identical across both backends
  • #160 — checked arithmetic now uses real overflow-detecting intrinsics
  • #161 — float printing is now shortest-round-trip by default, with exact fixed-precision and scientific formatting
  • #169 — out-of-range integer literals are now a hard error instead of silently wrapping
  • #170 — integer parsing can now reach minValue
  • #171 — zero-padded formatting put the sign after the padding
  • #207 — float-to-int casts now saturate on overflow
  • #211 — mixing int and float literals ([1, 2.5]) now unifies to float in every context
  • #216 — decimal-to-float parsing is now correctly rounded

Assignment & lowering

  • #139, #140, #143, #198 — assignments through tuple elements, globals, and computed properties were silently no-ops
  • #144 — a failable init delegating to a failing init returned a stale value instead of .None
  • #149, #151 — subscript setters with defaulted indices crashed at runtime; any codegen failure now fails the build instead of emitting a trapping binary
  • #179 — assignment through subscript and accessor-call targets didn't coerce the right-hand side
  • #191T?? now parses as a double optional in type position

Diagnostics & CLI

  • #166, #200 — string-interpolation errors pointed at the wrong location, and unparseable interpolation holes crashed the compiler
  • #197 — nested string interpolation dumped raw source text into the output
  • #205fatalError now prints its message to stderr
  • #209 — the CLI printed every inference error twice
  • #210 — ambiguous free-function calls rendered an internal Error type in the message