Write your Tauri backend in Kotlin.
A highly optimized Kotlin runtime bolted onto Tauri, with all the glue you need to write your entire backend in Kotlin, invokable directly from your frontend, fully typed, with zero IPC boilerplate.
Annotate a Kotlin function:
@ExportFunction
fun log(message: String = "ping", level: Int = 1) {
println("[$level] $message")
}
@ExportFunction
suspend fun fetchFromUrl(url: String): String {
delay(10.milliseconds) // real suspend work, on a coroutine
return "fetched $url"
}Call it from TypeScript as if it were local:
import { log, fetchFromUrl } from './bindings'
await log({ message: 'hello' }) // level defaults to 1
const page = await fetchFromUrl({ url: 'https://github.com' })No command names to register, no JSON schemas to keep in sync, no hand-written IPC. A KSP processor reads your Kotlin at compile time and writes all three halves of the bridge: the Kotlin dispatch registry, the TypeScript bindings, and the Rust JNI handover. None of them is written by hand, so none of them can drift.
Tauri gives you a tiny, fast shell and a real webview. Rust gives you a fast host. What neither gives you is the language most application logic is comfortable in, one with coroutines, null safety, data classes, default arguments and a huge library ecosystem. Kitsune bolts a Kotlin runtime onto a Tauri app and makes it feel native to both sides.
First-class Kotlin, not a lowest-common-denominator FFI. Default arguments,
suspend functions, coroutines, nullable types and object members all cross
the wire and behave exactly as you'd expect. Exports are driven entirely
by annotations.
Fast by construction. Every layer of the bridge was benchmarked and then rewritten around what the numbers said:
- CBOR, not JSON for JS ⟷ Rust ⟷ Kotlin. The payload crosses as bytes either way, so there's no reason to make a human-readable format nobody reads.
- No intermediary representation. Arguments are encoded once in the frontend and decoded once in Kotlin. No serde value, no JSON string, no map in between.
- Zero extra allocations on the call path. Nothing is copied, stringified or boxed just to move a call across and its result back.
- Integer ordinals, not strings. Function and event calls are addressed by ordinal, so dispatch allocates nothing.
- No reflection. Every serializer, decoder and dispatch table is generated at compile time (like serde), never discovered at runtime.
- A trimmed, pre-warmed JVM. The runtime that ships is a
jlinkimage containing only the modules your code uses, carrying an AOT cache recorded at build time and compressed withzstd, then embedded straight into the Tauri binary.
Obfuscated on the way out. The shipped jar is run through ProGuard before it
is embedded, so the compiled Kotlin ships renamed rather than in the clear. The
handful of names the Rust host and the JVM resolve by string (the
@KitsuneEntrypoint class, the JNI bridge, the AOT training main) are held
back from the rename; everything else is mangled, and @kotlin.Metadata is
stripped so the original names cannot be read straight back out. This is name
obfuscation, not a security boundary. It is a speed bump for reverse
engineering, on by default and switchable off. See
Configuration.
Here is the entire backend of a small app:
@ExportFunction
fun version(): String {
return "1.0"
}
@ExportFunction
fun add(a: Int, b: Int): Int {
return a + b
}
// Default arguments survive the wire. Omitting `times` in TypeScript
// is what makes the Kotlin default apply.
@ExportFunction
fun reverse(input: String = "", times: Int = 1): String {
return input.reversed().repeat(times)
}
// Nullable *and* defaulted. `{}` and `{ text: null }` are different
// calls, and the generated decoder can tell them apart.
@ExportFunction
fun label(text: String? = "untitled"): String {
return text ?: "<null>"
}
// `suspend` is a first-class export, not something you wrap by hand.
@ExportFunction
suspend fun fetch(url: String): String {
delay(10.milliseconds)
return "fetched $url"
}
// Members of an `object` work too.
object Store {
@ExportFunction(name = "load")
suspend fun load(key: String = "k", limit: Int = 10): String {
return "loaded $key/$limit"
}
}And an entrypoint, which is the only wiring you write:
@KitsuneEntrypoint
object TestApplication : KitsuneApplication() {
init {
println("Application initialized!")
}
}Build, and src/bindings.ts appears next to your frontend code:
/** `revxrsal.kitsune.aot.version` */
export function version(): Promise<string> { ... }
/** `revxrsal.kitsune.aot.add` */
export function add(args: { a: number; b: number }): Promise<number> { ... }
/** `revxrsal.kitsune.aot.reverse` */
export function reverse(args: { input?: string; times?: number } = {}): Promise<string> { ... }
/** `revxrsal.kitsune.aot.label` */
export function label(args: { text?: string | null } = {}): Promise<string> { ... }
/** `revxrsal.kitsune.aot.fetch` */
export function fetch(args: { url: string }): Promise<string> { ... }
// Members of an `object` are nested under a matching const, so the call site
// mirrors the Kotlin: `Store.load` there, `Store.load` here.
/** Members of the `Store` object. */
export const Store = {
/** `revxrsal.kitsune.aot.Store.load` */
load(args: { key?: string; limit?: number } = {}): Promise<string> { ... },
}Which you call like any other function:
import { add, reverse, Store } from './bindings'
const sum = await add({ a: 1, b: 2 }) // 3
const rev = await reverse({ input: 'abc' }) // "cba", `times` defaulted to 1
const row = await Store.load() // both defaults applyNotice what the types encode. a and b are required because Kotlin declares
them without defaults. key and limit are optional because Kotlin gives them
defaults, and the whole argument object defaults to {} because every parameter
is optional. A member of an object lands under a const of the same name, so
Store.load in Kotlin is Store.load in TypeScript. A suspend fun is
indistinguishable from a plain one at the call site: both are a Promise. That
mapping is derived from the Kotlin declaration, so it stays true by
construction.
Events are declared once and wired up on both sides for you.
@ExportEvent(id = "clicked")
@Serializable
class ButtonClicked(val x: Int, val y: Int)
@Listener
fun onButtonClicked(event: ButtonClicked) {
println("clicked at ${event.x}, ${event.y}")
}
@Listener
suspend fun onButtonClickedAsync(event: ButtonClicked) {
delay(10.milliseconds)
println("async click handled at ${event.x}, ${event.y}")
}The frontend gets a typed handle:
import { ButtonClicked } from './bindings'
ButtonClicked.emit({ x: 12, y: 40 })
const stop = ButtonClicked.listen(e => console.log(e.x, e.y))
stop()Suspending listeners each get their own coroutine under a supervisor job, so one failing or hanging listener cannot take the others down with it. An event nobody listens for is dropped without being decoded at all.
CBOR, not JSON. The payload crosses into the JVM as raw bytes either way. A text format would mean an encode on one side and a parse on the other, for something no human is ever going to read.
No intermediary JSON anywhere. Your arguments are encoded once in the frontend and decoded once in Kotlin. There is no serde value, no JSON string and no map in between, in either direction.
Zero extra allocations on the call path. Nothing is copied, stringified or boxed just to get a call from the webview to Kotlin and its result back. Calls are addressed by integer ordinal rather than by name, so even dispatch allocates nothing.
No reflection. Every decoder, every dispatch table and every call site is generated ahead of time at compile time, so nothing has to be discovered at runtime.
Coroutines all the way through. A call never blocks the host thread. Plain functions run straight through with no coroutine machinery at all; suspending ones are launched into a supervised scope, delivered back when they complete, and cancelled on the Kotlin side if the caller goes away.
A trimmed runtime, warmed ahead of time. The JVM that ships with your app
contains only the pieces your code actually uses, and it comes with an AOT cache
recorded at build time. Class loading and linking, normally the bulk of what a
JVM does before your first line runs, has already happened by the time the app
launches. For the sample app that is about 42 MB of runtime, a 3.9 MB jar and an
11 MB cache, all embedded in the binary and compressed with zstd.
Getting that right takes care, so the build does it for you. The cache is re-recorded whenever the code it was trained on changes, and every build checks that it genuinely engages rather than quietly falling back to a cold start. A build that cannot produce a working cache fails instead of shipping a slow app.
A JVM inside a desktop app invites one question ahead of every other: what does it cost in RAM? For the sample app, idle with its window open, the whole process tree runs from about 105 MB on macOS to 235 MB on Linux — and on every platform the webview, not the JVM, is what moves that number.
| Windows | macOS | Linux | |
|---|---|---|---|
| Whole process tree | 126 MB | ~105 MB | 235 MB |
| — webview processes | ~96 MB (76%) | ~54 MB (52%) | ~114 MB (49%) |
| — host process 1 | 29.4 MB (24%) | 50.8 MB (48%) | 102.2 MB (43%) |
| Post-GC live heap | 1.1 MB | ~1.0 MB | 1.0 MB |
| Metaspace used | 2.2 MB | 1.9 MB | 2.1 MB |
| Code cache used | 1.6 MB | 1.5 MB | 1.6 MB |
| JVM threads | 18 | 18 | 18 |
| Time to a visible window (warm) | 542 ms | — | 1.07 s |
| Time to steady-state memory | 2.8 s | — | 1.6 s |
There is no java process on any platform: libjvm is loaded from the unpacked
runtime and the VM created in-process through the invocation API, so the whole
Kotlin runtime lives inside the host process alongside the Rust side. On Linux
/proc/<pid>/smaps lets that share be attributed exactly rather than inferred:
libjvm.so 17.6 MB
AOT cache (app.aot) 9.2 MB
Java heap (resident) 4.6 MB
jlink modules image 1.9 MB
JIT code cache 1.0 MB
metaspace + other runtime 0.9 MB
────────
JVM total 35.1 MB (15% of the whole tree)
Almost all of that is fixed runtime overhead rather than anything the app
allocated — libjvm.so and the AOT cache alone are 27 of the 35 MB. It scales
with how much Kotlin is live at once, not with how much you write.
Committed is not resident. Every platform reports a much scarier reservation
figure than its resident one: +864 MB of commit on Windows, ~253 MB of reserved
heap on macOS, 538 MB committed on Linux. That is -Xmx512m with no -Xms, so
HotSpot's ergonomics pick an initial heap of 1/64 of physical RAM and commit it
up front to hold a ~1 MB live set. Linux NMT puts an exact number on the gap:
512 MB of the 538 MB is Java heap, of which 4.6 MB is actually resident, leaving
26 MB of real JVM structures. Those pages are never touched, so they cost
address space rather than memory. Adding -Xms32m to vmOptions removes most
of the commit without moving the resident figure; it re-records the AOT cache,
which the build does for you.
About these numbers. Each column is one machine running the release build of the sample app, warm, idle at its window:
- Windows — 11 Pro 26200, i5-13400F, 32 GB, WebView2 151.0.4129.107. Private working set, tree sampled every 500 ms for 60 s.
- macOS — 26.5.2 (25F84), M2 Pro, 16 GB, system WebKit 26.5.2.
vmmap --summaryphysical footprint.- Linux — Fedora 43, KDE Plasma 6.6.5 on Wayland, kernel 7.0.9, i5-13400F, 32 GB, RTX 3080 (NVIDIA 580.126.18), WebKitGTK 2.52.3, bundled JDK 25.0.2. Release AppImage; PSS from
/proc/<pid>/smaps_rollup, sampled every 500 ms for 60 s, steady-state median from t = 15 s. Private (USS) was 191 MB and summed RSS 430 MB — the latter double-counts shared pages and is not worth quoting. Startup is the median of five warm launches under XWayland, the only backend here where window mapping is observable externally. The.deb/.rpmbinary measured 232 MB, the same within noise. SystemMemAvailabledrop is omitted: it ranged 47–260 MB across three cycles while tree PSS held within 3 MB, so it measures the rest of the machine more than it measures this app.Each platform is measured the way it reports memory, so the three columns are the same question answered in three dialects rather than one directly comparable figure. A benchmark is a snapshot of one workload on one configuration: your heap, your webview content, your OS and your GPU will all move these numbers. Treat them as an order of magnitude and measure your own app.
kitsune/
├── src/ React frontend
│ ├── Bridge.ts hand-written transport (CBOR, ordinals, event pump)
│ └── bindings.ts GENERATED, one binding per export
├── src-tauri/ the Rust host
│ ├── src/jvm/ JVM startup, dist layout, embedded image unpacking
│ │ └── entrypoint.rs GENERATED from @KitsuneEntrypoint
│ ├── src/commands/ call_kt, pending-call table, cancellation
│ ├── src/events/ the event pump
│ ├── src/wire.rs how each direction carries its ordinal
│ └── build.rs builds the Kotlin half, embeds dist/ into the binary
└── src-kitsune/ the Kotlin module
├── src/main/kotlin/... your code, plus the small runtime
├── codegen/ the KSP processor
├── buildSrc/ jlink, jdeps and AOT cache Gradle tasks
└── dist/ build output: runtime/, lib/app.jar, lib/app.aot
The build is a single chain. Running shadowJar is finalized by dist, which
builds the jlink image, writes the VM options, records the AOT cache and
verifies it engages. assemble depends on dist, and check depends on the
verification, so there is no configuration in which you end up with a jar and a
stale cache. The jar is built directly into dist/lib rather than copied there,
so there is only ever one location for it.
On the Rust side, build.rs runs Gradle and then embeds dist/ as a
deterministic zstd tarball, named by its blake3 hash. Release builds unpack it
once into a cache directory keyed by that hash; debug builds skip the embedding
entirely and read src-kitsune/dist off disk, so iterating on the Kotlin half
does not mean recompressing 54 MB.
Requirements: JDK 25 (Gradle's toolchain support will fetch one), Rust, and Node with pnpm.
pnpm install
cd src-kitsune && ./gradlew dist && cd ..
pnpm tauri devThe Gradle build is what produces src/bindings.ts and
src-tauri/src/jvm/entrypoint.rs, so run it once before the first tauri dev.
After that, cargo reruns it for you whenever a tracked Kotlin input changes.
For a release build:
pnpm tauri buildAn @ExportFunction must be public, and either top-level or a member of an
object. It may be suspend. It may return Unit, in which case the reply is
zero bytes and the binding is typed Promise<void>. Parameters and return types
have to be serializable by kotlinx.serialization, which for your own types means
@Serializable.
@ExportEvent classes must be @Serializable. A @Listener takes exactly one
parameter, and may be suspend. If you leave @Listener(event = ...) blank,
the event is taken from the parameter type.
Names must be unique within the module. Left blank, @ExportFunction uses the
function's own name and @ExportEvent uses the class's simple name, without the
package, so the Rust and TypeScript sides never have to mirror your Kotlin
package structure.
Everything about how the module becomes an app lives in one block:
kitsune {
// Where the generated TypeScript lands. Unset means "generate nothing".
bindings.set(layout.projectDirectory.file("../src/bindings.ts"))
// Where the generated JNI handover lands.
entrypoint.set(layout.projectDirectory.file("../src-tauri/src/jvm/entrypoint.rs"))
// Class whose main() is run to record the AOT profile.
trainingMainClass.set("revxrsal.kitsune.aot.Training")
// Shared by the AOT training runs and the host process. Changing this
// list re-records the cache instead of breaking it.
vmOptions.set(listOf(
"-XX:+UseCompactObjectHeaders",
"-XX:+UseSerialGC",
"-Xmx512m",
"--enable-native-access=ALL-UNNAMED",
))
}compression (zip-0 through zip-9) and excludeFiles are available too, for
squeezing the jlink image further.
Obfuscation is on by default and configured here as well:
kitsune {
// Run the shipped jar through ProGuard. On by default; set false to ship
// the jar un-renamed.
obfuscate.set(true)
// The keep rules. Defaults to proguard-rules.pro beside the build script,
// which exempts the names the Rust host and the JVM resolve by string.
obfuscationRules.set(layout.projectDirectory.file("proguard-rules.pro"))
// The com.guardsquare:proguard-base version. Bump it when the JDK's
// class-file version outpaces what ProGuard can read.
proguardVersion.set("7.10.0")
}The jar is run through ProGuard whether or not obfuscation is enabled (with it
off the jar is copied through untouched), so the same dist/lib/app.jar layout
is produced either way and the AOT cache always trains against the jar that
actually ships. The default rules keep it to renaming only (-dontshrink -dontoptimize); shrinking and optimization are yours to enable once you have
confirmed a clean build.
The cache is recorded by running Training.main, not your application's real
entrypoint. Training runs under the bundled JVM with no Rust host in the
process, so anything declared external is unbound. Declaring natives is fine,
since the JVM binds one only at its first invocation; calling one during
training would throw and fail the build.
Training discovers and links every class in the revxrsal.kitsune package
tree straight out of the jar, so new code is covered as soon as it compiles. It
loads with initialize = false deliberately. As the Kotlin side grows a
connection pool or an HTTP client, constructing those in Training is where the
cache starts earning real money.
This is a working prototype rather than a published library. The sample exports
under revxrsal.kitsune.test exist to exercise every shape the codegen has to
handle: no arguments, all-required, defaulted, nullable-and-defaulted, Unit,
object members, and each of those again as suspend. Treat them as the test
suite they are, and delete them when you start your own app.
It has been developed against Linux, macOS and Windows, and the runtime path logic is verified working on all three.
Footnotes
-
Rust host + JVM. On Linux it also contains the WebKitGTK UI process and GTK itself, which is why it is larger there and why the webview/host split is not directly comparable across the three. ↩