The backend compiles a Java HTTP handler into a native executable. It uses the same ParparVM pipeline as the iOS, Windows and Linux ports: javac produces bytecode, ParparVM turns that into C, and clang compiles the C together with the runtime into one binary. Nothing interprets anything at run time, and there is no JVM underneath.
The measurements in this chapter come from vm/backend/benchmarks on two pinned
cores against 64 connections, and the harness is in the repository so you can
disagree with them.
What this doesn’t replace
Spring Boot, Quarkus, Micronaut and Jakarta EE aren’t the competition here. They carry dependency injection, an ORM, declarative transactions, a security stack and two decades of operational knowledge, and none of that exists in this runtime. If you are running a Spring service today and it works, this chapter is not asking you to move it.
What those frameworks assume is a JVM, and that paying for one is reasonable. For most server work that assumption holds. This exists for the work where it fails.
Why it exists
A JVM charges you twice. It charges start-up on every cold process, and it charges a baseline heap for as long as the process lives. Both are fine when a service runs for weeks and handles millions of requests. Neither is fine when the process exits after 200 milliseconds, when you are billed per invocation, or when the instance is capped at 128 MB.
That describes a specific and growing slice of server work: serverless functions, sidecars, edge workers, webhook receivers, small always-on services. Java is thin on the ground there, and the reason is arithmetic rather than taste. Teams therefore reach for Go or Node, and a Java shop that does this ends up with two languages, two toolchains, and two definitions of every object that crosses the wire.
The point of the backend is to remove the reason to leave Java for that slice. It doesn’t try to take work the JVM already does well.
A first server
The archetype and the initializr both generate a backend module beside the
client ones:
myapp/
common/ shared app code
javase/ desktop build
ios/ iOS build
android/ Android build
backend/ the server
pom.xml
src/main/java/com/example/myapp/Notes.javaA server is a class with routes on it. The annotations are Spring’s, under Codename One’s package names, so this reads the same way to anyone who has written a Spring controller:
@RestController
@RequestMapping("/notes")
public class Notes {
private final Map<Long, Map> store = new ConcurrentHashMap<Long, Map>();
private final AtomicLong nextId = new AtomicLong(1);
@GetMapping("/healthz")
public String health() {
return "ok";
}
@GetMapping("/{id}")
public Map read(@PathVariable("id") long id) {
return store.get(Long.valueOf(id)); // null becomes a 404
}
@GetMapping
public List list(@RequestParam(value = "limit", defaultValue = "20") int limit) {
List page = new ArrayList();
for (Map note : store.values()) {
if (page.size() >= limit) {
break;
}
page.add(note);
}
return page;
}
@PostMapping
@ResponseStatus(201)
public Map create(@RequestBody Map note) {
long id = nextId.getAndIncrement();
Map stored = new LinkedHashMap(note);
stored.put("id", Long.valueOf(id));
store.put(Long.valueOf(id), stored);
return stored;
}
@DeleteMapping("/{id}")
@ResponseStatus(204)
public void delete(@PathVariable("id") long id) {
store.remove(Long.valueOf(id));
}
}
There is no main here, and that’s the point. Every server opens with the same
twenty lines — start a listener, install a shutdown handler, wait on it — and
getting any of them wrong produces one that leaks connections on SIGTERM, or one
that ends the moment main returns and says nothing about why. The build writes
those lines, from the controllers it finds.
It also writes the router. The obvious hand-written form,
if ("/healthz".equals(request.getTarget())), builds a String for the target,
hashes it and compares it — for every route it tries before the one that matches,
on every request — and breaks as soon as a client appends ?probe=1. The
generated router holds each route as a byte[] and asks the request whether its
path bytes are those bytes, so a route with no path variables allocates nothing at
all and a query string can’t break it.
The return value decides the response. A String is sent as text, anything else as
JSON, null is a 404, and @ResponseStatus sets the code for the cases where 200
isn’t it. A handler that needs something this doesn’t model takes the
HttpServer.Request itself and answers exactly as it would have before.
Two commands matter:
# the property is not optional: the backend module lives in a profile, and # without it Maven cannot see it in the reactor at all mvn -pl backend -Dcodename1.platform=backend cn1:backend # run it on this JVM mvn -pl backend -Dcodename1.platform=backend cn1:backend-package # build the native binary
The first is the development loop. Both run the same protocol code — there is one
copy of HttpServer, and only the layer underneath it differs — so behaviour
can’t drift between the loop you develop in and the binary you ship. The local
run doesn’t terminate TLS, by design, and therefore doesn’t serve HTTP/2:
both refuse with a message that says so, because a second TLS implementation
would have its own bugs rather than production’s.
Virtual threads and the request loop
The concurrency model is the part most worth understanding, because it’s what makes a handler that blocks acceptable.
Every connection gets a virtual thread. Not a pooled worker that a connection borrows for one request — a stack that belongs to that connection until it closes. The handler can block on a socket read, a database round trip or a file, and it costs a parked stack rather than an OS thread.
Host threads run those virtual threads, and there is one host per core. A descriptor is registered in exactly one host’s epoll set, so it can only ever be reported to that host, and only that host ever touches its virtual thread. That affinity is why the scheduler needs no locks on the hot path.
The loop is: the host polls, a descriptor becomes readable, the host resumes that connection’s virtual thread, the handler runs until it needs bytes that haven’t arrived, and it parks. Parking returns control to the host, which polls again. When the handler finishes a response the descriptor stays armed, so the next request on that connection costs no system call to set up.
Host count follows cores rather than the workers argument. On two pinned cores,
16 hosts served 117 requests where 2 hosts served 257,297: past one host per core
they compete for the cores the server needs. In this mode workers stops meaning
"requests in flight," because the virtual threads supply that.
Configuration and profiles
A server runs against a file on a laptop and a managed database in production, and the difference between those two can’t live in the source. It lives in properties files beside the binary and in the environment:
application.properties, committed:
cn1.datasource.url=${DATABASE_URL}
cn1.server.port=8080
application-dev.properties, also committed:
cn1.datasource.url=:memory:
CN1_PROFILE=dev ./server # SQLite, nothing installed DATABASE_URL=postgres://app:secret@db/app ./server # production
A key is resolved in one order, and the first layer that has it wins: a system
property of that exact name, then the environment variable it maps to
(cn1.datasource.url becomes CN1_DATASOURCE_URL), then the environment
variable a platform already sets for it, then
application-<profile>.properties, then application.properties.
The third layer is worth spelling out. PORT and DATABASE_URL are set for you
by every platform-as-a-service worth the name, and a server that ignored them
would need a wrapper script to start at all, so they’re read where those two
keys are read.
A value may name an environment variable as ${NAME} or ${NAME:fallback}, and
that reference resolves when the value is READ rather than when the file loads.
That’s what lets the committed application.properties above name a variable
only production sets: the development profile overrides the key, so the unset
variable is never read. A reference that’s read and can’t be resolved fails
with a message naming it, because the alternative is a server that opens a SQLite file
called ${DATABASE_URL} and finds it empty.
Nothing here is required. A binary with no properties file beside it reads its
whole configuration from the environment, which is the normal shape for a
container built FROM scratch: there is no file next to the binary because
there is nothing next to the binary.
| Key | What it sets |
|---|---|
| The active profile. |
| A SQLite path, or a |
| How many connections to hold. The default is eight for a server engine, four for a SQLite file and one for an in-memory database. |
| The port. Also read from |
| The size of the request thread pool. |
| A PEM certificate and key to terminate TLS with. One without the other is refused rather than serving plaintext under a name that promises otherwise. |
| A directory to serve files from, after every route. |
| Whether the generated daos create their tables at start-up. True on a development profile, false everywhere else. |
Handlers that need any of this get it from the entry point the build writes.
Only a server that writes its own main names the builder behind it:
Backend.builder()
.port(9000) // otherwise cn1.server.port, or PORT, or 8080
.handler(new Health())
.run();
run() reads the configuration, opens the database, binds, installs a shutdown
handler that drains what’s in flight, and blocks. Anything the builder is told
is used as given, and anything it isn’t told comes from the configuration — so a port written in the source is the port, and a port that should follow the
deployment is one nobody writes there.
Talking to a database
DataSource.open takes a SQLite path or a PostgreSQL or MySQL URL, pools
connections to it, and returns rows as the same Java types whichever engine
answered:
DataSource db = DataSource.open(System.getenv("DATABASE_URL")); // or ":memory:"
List rows = db.query("SELECT id, body FROM note WHERE id > ?",
new Object[] { Integer.valueOf(10) });
There is no JDBC driver involved. SQLite is linked into the binary, and the
PostgreSQL and MySQL clients speak their wire protocols directly, and a
mariadb:// URL is served by the same client. MySQL 8.0 and MariaDB 10.4 and
newer are supported: a string key needs a case-sensitive NO PAD collation, so
"A" and "a" are two keys and "token " keeps its space, and the two server
families spell that collation differently. Which one is used comes from the
server rather than from the URL scheme, so a mysql:// URL pointed at MariaDB
is still correct.
Statements are written once, in one portable form: ? for every parameter, and
plain unquoted names. PostgreSQL binds $1 rather than ?, and that difference
stops inside execute and query rather than at every call site. SQL already
written for one engine keeps working, because a statement carrying no ? at all
is passed through untouched — so hand-written $1 is left alone. A literal
question mark that isn’t a parameter is written ??, which matters on
PostgreSQL, whose jsonb operators are spelled ?, ?| and ?&.
A parameter count that doesn’t match the statement is refused before the statement is sent. The three engines answer a mismatch three different ways, and SQLite’s answer is to bind the missing parameters to NULL and commit the row.
The other thing the engines disagree about is the key an insert generated.
insert asks whichever way this engine answers — last_insert_rowid() on
SQLite, LAST_INSERT_ID() on MySQL, and INSERT … RETURNING on PostgreSQL,
which has no last-insert-id concept at all:
long id = db.insert("INSERT INTO note (body) VALUES (?)",
new Object[] { "first" }, "id");
Several statements that have to be one go through inTransaction, which holds a
single connection for the whole body and rolls back if it throws:
db.inTransaction(new DataSource.Work() {
public Object run(Database connection) throws Exception {
connection.execute("UPDATE account SET balance = balance - ? WHERE id = ?",
new Object[] { Integer.valueOf(100), Integer.valueOf(1) });
connection.execute("UPDATE account SET balance = balance + ? WHERE id = ?",
new Object[] { Integer.valueOf(100), Integer.valueOf(2) });
return null;
}
});
Everything each engine spells differently is reachable through db.dialect(),
which is what code that generates schema needs:
Dialect dialect = db.dialect();
String create = "CREATE TABLE IF NOT EXISTS " + dialect.quote("note") + " ("
+ dialect.quote("id") + " " + dialect.generatedKeyColumn(Dialect.BIGINT) + ", "
+ dialect.quote("body") + " " + dialect.columnType(Dialect.TEXT) + ")";
Quoting every identifier isn’t caution. PostgreSQL folds an unquoted name to
lower case while SQLite and MySQL preserve it, so a column called createdAt
becomes createdat on one engine of the three, and code that reads rows by name
stops finding it there.
A Database — one connection rather than a pool — is still available through
Database.open for code that wants exactly one, and every one of its operations
is synchronized, so sharing one across handlers is safe and serialized. That’s the
right shape for a single-file SQLite server and the wrong one for a database
that’s a machine across a network: there the single connection isn’t a safety
property, it’s the bottleneck. The pool is the default for that reason.
Storing objects
A class with @Entity on it gets a data access object written for it at build
time. The annotations are the ones the SQLite ORM chapter documents, and they
are the same annotations in the same package, because an entity is the one class
both halves of an application own:
@Entity(table = "reminders")
public class Reminder {
@Id public long id;
@Column(nullable = false) public String title;
public java.util.Date due;
public boolean done;
@DbTransient public String cachedLabel; // never stored
public Reminder() {
}
}
What differs between the app’s copy and the server’s is what the build generates from it: a module compiled against the Codename One core gets a dao over the local SQLite database, and a module compiled against this runtime gets one whose statements are built for whichever engine the connection turns out to be. The class says what the data is; the module says where its rows live.
Dao<Reminder> reminders = em.dao(Reminder.class);
Reminder reminder = new Reminder();
reminder.title = "renew the certificate";
reminder.due = new Date();
reminders.insert(reminder); // reminder.id is now the generated key
Reminder stored = reminders.findById(Long.valueOf(reminder.id));
stored.done = true;
reminders.update(stored);
The entity manager comes from the entry point, which opens one when the build generated at least one entity. A controller asks for it by declaring a constructor that takes one, and the generated entry point calls that constructor:
@RestController
@RequestMapping("/reminders")
public class ReminderApi {
private final Dao<Reminder> reminders;
/** The entry point calls this one because it is the one declared. */
public ReminderApi(EntityManager entities) {
this.reminders = entities.dao(Reminder.class);
}
@GetMapping
public List<Reminder> outstanding() throws IOException {
return reminders.query().eq("done", Boolean.FALSE).orderBy("due", true).list();
}
}
That’s the whole of the injection. A constructor taking a DataSource gets the
pool instead, a constructor taking no arguments is called with none, and a
controller that needs something else builds it itself. There’s no container: the
generated entry point holds a new with the argument written into it, and a
controller that declares a dependency nothing configured is refused at start-up
rather than handed a null to fail on later.
The generated entry point is also the only place that registration can live, and
that’s a consequence of the build order rather than a preference. This runtime
has no reflection and the translator drops a class nothing references, so the
generated cn1app.BackendDaoBootstrap holds a direct reference to every dao and
is what keeps them in the binary. It’s written during process-classes, AFTER
javac has compiled the module’s own sources — so a main you write yourself
can’t name it, and a module that sets its own mainClass can’t reach the
generated daos at all. Use DataSource directly there, or let the build write
the entry point and put the startup work in a controller.
Opening an entity manager is otherwise a single call over a pool, which is what a test does:
EntityManager em = EntityManager.open(pool);
Queries name JAVA FIELDS rather than columns, and the builder quotes the column each one maps to:
List<Reminder> overdue = em.dao(Reminder.class).query()
.eq("done", Boolean.FALSE)
.lt("due", new Date())
.orderBy("due", true)
.limit(20)
.list();
A name that isn’t a field of the entity is refused at once, listing the
ones that are, rather than reaching the server as a column it doesn’t have.
eq, ne, gt, gte, lt, lte, like, in, isNull and isNotNull are
joined with AND in the order they were added, and list, first, count and
delete end the chain. A query the builder can’t express takes SQL instead,
through dao.find(where, params), which is the point at which portability
becomes yours to keep.
Transactions take the same shape as everywhere else: the entity manager the body is handed is pinned to one connection, so every dao reached through it runs inside the transaction.
em.transaction(new EntityManager.Work() {
public Object run(EntityManager tx) throws Exception {
Dao<Reminder> reminders = tx.dao(Reminder.class);
Reminder first = reminders.findById(Long.valueOf(1));
first.done = true;
reminders.update(first);
reminders.insert(follower(first));
return null;
}
});
Three things this doesn’t do, on purpose. Relationships aren’t supported — @OneToMany and friends don’t exist, and a field referencing another entity
fails the build with a message saying to persist the foreign key as a scalar.
createTable creates a table that isn’t there and does nothing at all to one
that is, so it’s a convenience for development and for tests rather than a
migration tool. And a boolean, a date and a char are stored as integers on every
engine — 0 or 1, epoch milliseconds, and the UTF-16 code unit — because a
native timestamp comes back as text whose format follows the server’s own time
zone and would not round-trip the same way on three engines, and because a char
that was never assigned holds NUL, which PostgreSQL refuses inside a text value.
A field declared as a primitive gets a NOT NULL column, because a primitive has
no null to read: a nullable column would load as 0, false or \0, which is
indistinguishable from a row that holds those. Declare the field as its boxed
type — Integer rather than int — when the column can be empty.
Sharing the contract with the app
This is where having the same language on both ends stops being a slogan. An interface annotated for the REST client generates the app’s client:
@RestClient
public interface NotesApi {
@GET("/notes/{id}")
void note(@Path("id") String id, OnComplete<Response<Note>> callback);
@POST("/notes")
void create(@Body Note note, OnComplete<Response<Note>> callback);
}
Building the backend module with -Dcn1.restServer=true generates two more types
from that same interface: NotesApiServer, a synchronous interface the backend
implements, and NotesApiDispatcher, which routes a method, path and body to it
and binds the path and query parameters.
public class NotesEndpoint implements NotesApiServer {
private final Map<String, Note> notes = new ConcurrentHashMap<String, Note>();
public Note note(String id) { // no callback: this IS the server
return notes.get(id);
}
public Note create(Note note) {
notes.put(String.valueOf(note.id), note);
return note;
}
}
The client’s methods are asynchronous because a UI can’t block; the server’s methods are synchronous because a handler has nothing to call back into. One declaration produces both shapes, which is what gRPC does and for the same reason.
The payoff is that changing the contract breaks the build on whichever side did not follow it, instead of producing a response the app fails to parse in the field. The data transfer objects are shared rather than transcribed, and their codecs are generated on both sides, so there is no handwritten mapping layer to drift.
The server half is off by default. Every existing project carries these interfaces for its client alone, and generating server classes into those builds would grow them for nothing.
What it costs
Same handler, three ways, plus Go for an outside reference. Two pinned cores, 64 connections, medians of three interleaved runs on the plaintext route:
| Runtime | Requests/sec | p50 | p99 | Cold start |
|---|---|---|---|---|
Codename One native, musl | 595,610 | 0.090 ms | 0.249 ms | 0.77 ms |
Codename One native, glibc | 547,761 | 0.065 ms | 4.06 ms | 2.88 ms |
Go, fasthttp | 496,293 | 0.104 ms | 2.63 ms | 2.39 ms |
The same handler on the JVM | 187,745 | 0.260 ms | 1.60 ms | 82.5 ms |
Cold start is the interesting column. It’s measured from process spawn to the first accepted connection, and the static binary reaches it in under a millisecond — about a hundred times faster than the same code on a JVM, and three times faster than Go. That number is the whole serverless argument.
Throughput is the least interesting one. Beating a tuned Go server by a fifth on a microbenchmark isn’t a reason to move a service; it’s only evidence that the translation doesn’t cost you anything.
The slope chart is the one that matters. Every runtime here has a similar median. What differs is the distance to the 99th percentile, and that distance is garbage collection. With the response pooled the plaintext route allocates about 0.1 bytes per request, the collector never runs, and the tail stays at 2.8 times the median. Give the same server a handler that allocates a map per request and its tail goes to 80 ms, because the collector shares the cores with the server.
The honest rule is that the tail follows your allocation rate, not the runtime badge. The runtime gives you the tools to allocate nothing on the hot path; it doesn’t do it for you.
Memory and size
| Runtime | Binary or artifact | Resident under load |
|---|---|---|
Codename One native, musl | 7.95 MB static | 10-40 MB |
Codename One native, glibc | 3.19 MB dynamic | 14 MB |
Go, fasthttp | 5.63 MB static | 6.3 MB |
The same handler on the JVM | 0.13 MB jar, plus a JRE | 190 MB |
The JVM row is the same handler and the same protocol code. Everything it costs above the native rows is the runtime underneath it.
The resident figures move around more than the latency ones, because the collector keeps a pool of pages sized to the busiest moment the process has seen and gives them back gradually. At rest the native builds sit near 3 MB.
musl or glibc
Both are supported, and they aren’t equivalent. The static musl build starts faster and has a far shorter tail. The glibc build has a better median, because its allocator is better under contention, and a smaller binary, because it links the system libraries instead of carrying them.
The reason to pick musl isn’t the median. It’s that the artifact is one file with nothing underneath it, which is what makes the container the binary and the cold start a process exec.
What isn’t measured here
GraalVM is missing from these tables on purpose. A fair comparison would have to run the same handler, and this handler can’t run on GraalVM: the runtime’s native methods are ParparVM’s, so comparing would mean benchmarking a different server written against a different framework and reporting it as though the toolchains had been compared. That’s a benchmark worth building, and it isn’t this one.
Deploying it
The musl build is a single static file, so the container that carries it can be empty:
FROM scratch COPY bench-linux-musl-arm64 /server ENTRYPOINT ["/server"]
There is no base image to patch, because there is no base image. cn1:backend-package
builds for the machine it runs on; the cross-compiled targets
(musl-x86_64, musl-arm64, glibc-x86_64, glibc-arm64) are produced by
vm/backend/package.sh in the Codename One repository, which drives one builder
image per target.
For AWS Lambda, LambdaRuntime implements the custom runtime loop. The Lambda
Runtime API is a plaintext poll over loopback, so it needs no listening socket and
no TLS, and what it does need is exactly what a translated binary is good at.
Limits worth knowing
The class library is the Codename One runtime, not Java SE. A server dependency that assumes the full JDK won’t translate, and Maven Central isn’t the ecosystem this draws on.
Routing, configuration, a connection pool and a build-time ORM are the whole of the framework. What a controller can be handed is the pool or the entity manager, by declaring a constructor that takes one; there is no container, no aspect layer and no starter ecosystem, and validation and error mapping are written by hand or generated from the REST contract.
The packaging goal compiles Java. It recompiles the module’s sources against the backend’s class library instead of reusing the jar Maven built, which is what keeps a server off classes the runtime doesn’t have, and is also why Kotlin is not wired into this path yet even though the client ports support it. It uses the JDK running Maven — any release from 8 up — and
-Dcn1.backend.jdknames a different one. What it can’t change is the language level: the module’s sources are compiled at Java 8, because that’s the bytecode the translator reads.A virtual thread parks on the socket it’s SERVING, and not on any other. An outbound read — a database query, an HTTP call to another service — blocks the host thread that’s running it, because only the server’s own descriptors are registered with a poller that can wake them. With one host per core, that many concurrent slow outbound calls occupy every host and other connections wait. Servers that mostly compute and serve get the full benefit; servers whose handlers spend their time waiting on a database should size for that, or run the thread pool with
CN1_HTTP_POLL_MODE=0.TLS runs on the thread pool, whatever the poll mode says. The TLS layer can’t park a read yet, and a blocking read on a virtual thread holds its host for the duration, so one idle TLS client per core would occupy every one of them. The server says so at startup when it makes that choice.
Native builds target Linux. Development happens anywhere a JVM runs.
The reasons to choose this are cold start, footprint, deployment shape, and one language across the app and its server. If none of those matter for the service in front of you, use a JVM framework.