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.

Java compiled to C to a native binary, and a request travelling through a host thread and a virtual thread to the handler
Figure 245. The build pipeline and the request path

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.java

A 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.

KeyWhat it sets

cn1.profile

The active profile. dev, development, test and local are development profiles, which changes two defaults and nothing else.

cn1.datasource.url

A SQLite path, or a postgres:// or mysql:// URL. Also read from DATABASE_URL. On a development profile an unset value means an in-memory SQLite database; on any other profile it’s an error, because a service that writes into a database inside its own process, with nothing saying so, loses everything at the next deploy.

cn1.datasource.pool.size

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.

cn1.server.port

The port. Also read from PORT.

cn1.server.workers

The size of the request thread pool.

cn1.server.tls.certificate, cn1.server.tls.key

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.

cn1.static.root

A directory to serve files from, after every route.

cn1.orm.createTables

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:

RuntimeRequests/secp50p99Cold 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.

A slope chart showing that the musl build’s tail stays close to its median while the others fan out
Figure 246. Latency at the median against the 99th percentile

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

RuntimeBinary or artifactResident 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.jdk names 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.