Java on Cloudflare Workers
bytebox compiles a Java workspace into a Cloudflare Worker. Write a class, apply the Gradle plugin, and deploy WebAssembly that runs on Cloudflare's edge.
A hello world Worker starts in 10 ms and answers a request in under a millisecond of CPU.
- Why bytebox
- Install
- Quick Start
- Triggers
- Bindings
- JSON
- Standard Library
- Serialization
- npm Packages
- Startup
- Concurrency
- Platform Limits
- Out of Scope
- License
Cloudflare Workers run JavaScript and WebAssembly. Java compiles to WebAssembly through TeaVM, but the gap between a compiled module and a deployable Worker is wide enough that nothing crossed it: TeaVM publishes no npm artifact, and its generated loader cannot run on Workers at all. Compilation from bytes is refused inside a request; the asynchronous form never settles during module evaluation; streaming compilation is unavailable in both places.
bytebox supplies the loader that does work, the bindings a Worker needs, and the build that ties them together.
The Gradle plugin:
plugins {
java
id("dev.gmitch215.bytebox") version "1.0.1"
}
dependencies { implementation("dev.gmitch215:bytebox-core:1.0.1") }The loader, for a Worker assembled by hand:
bun add @gmitch215/byteboxpackage com.example;
import dev.gmitch215.bytebox.*;
public class HelloWorker implements Worker {
@Override
public Response fetch(Request request, Env env, ExecutionCtx ctx) {
return Bytebox.response("hello from Java");
}
}bytebox {
handlerClass = "com.example.HelloWorker"
wrangler {
name = "hello-world"
compatibilityDate = "2026-08-22"
}
}./gradlew buildWorker
./gradlew workerDeploybuildWorker writes a complete Worker into build/bytebox/worker: the module, a wrangler.jsonc,
a JavaScript entry point and a package manifest. Nothing there is hand-written.
samples/ has nine of these, one per feature, and samples/ADVANCED_USAGE.md is the design document behind them.
One interface per trigger, each optional. The build derives which handlers to export and which Wrangler keys to write from the interfaces a class implements, so an unhandled trigger costs nothing.
| Interface | Handles |
|---|---|
Worker |
HTTP requests |
Scheduled |
Cron Triggers |
Mail |
Incoming email |
Consumer<T> |
Queue messages |
Tail |
Trace events from another Worker |
Alarm |
Durable Object alarms |
InboundMail records its own disposition, because a message a handler returns without acting on is
dropped by the platform. Call forward, reply or reject at any point and carry on working;
drop() states that discarding it was the intent.
Binding names are optional. Each type has a default, and repeats take a numeric suffix: the first KV
namespace is KV and the second KV_2, the first D1 database is DB, the first R2 bucket is BLOB,
and a Durable Object binding is DO_ followed by the class name.
Remote identifiers are supported wherever Wrangler accepts one, and omitting them lets Wrangler provision the resource.
bytebox {
bindings {
kv() // KV
kv("SESSIONS") { id = "abc123" } // an explicit name and a remote id
d1() // DB
r2() // BLOB
durableObject("Counter") // DO_COUNTER
}
}Or, when every binding takes its default name:
bytebox {
bindings(KV, D1, D1, KV) // KV, DB, DB_2, KV_2
}bindingsReport prints every declared binding and the resource it points at, which is where a name
that drifted shows up.
Annotate a type and the build writes its codec:
@JsonType
public record Order(String sku, int quantity, long total, List<String> tags) {}Order order = request.json(Order.class);
return Bytebox.json(order, Order.class);Generated rather than reflective, because a reflective decoder needs field metadata for every type
that could arrive through an Object-typed field β the closure dead-code elimination exists to prune.
request.json(mapper) takes a function instead, for a type with no codec.
A long serialises to a JSON string. JSON.stringify refuses a BigInt outright, and a number would
lose precision above 2^53, which is the range a long exists for.
Write java.time, java.net and java.util.regex the way you write them anywhere. The compiler
points each reference at an implementation that works on this runtime, so a library that never heard
of Cloudflare Workers compiles unchanged.
ZonedDateTime local = Instant.now().atZone(ZoneId.of("America/New_York"));
HttpResponse<String> answer = HttpClient.newHttpClient()
.send(HttpRequest.newBuilder(URI.create("https://example.com")).build(), ofString());| Package | Runs on |
|---|---|
java.time |
ThreeTen-Backport, with zone rules from Intl |
java.net.URL |
fetch |
java.net.http |
fetch, without sendAsync |
java.net.Socket |
cloudflare:sockets |
java.net.InetAddress |
DNS over HTTPS |
java.util.regex |
the platform's own engine |
java.util.Formatter |
digits computed in Java, separators from Intl |
Each one either matches a JVM or refuses while you are building. A pattern using an atomic group, a
possessive quantifier or a flag written inside it will not compile, and Pattern.compile names what
it refused. String.format refuses %t and %a. ServerSocket and DatagramSocket are left
unresolved, because the platform accepts no inbound connection and speaks no UDP.
Nothing about this is free of consequences, and they are written down. The clock is pinned between
I/O, so Instant.now() gives the time the invocation began. CASE_INSENSITIVE folds the whole of
Unicode here where a JVM folds ASCII. A timezone's recorded history is not available, because the
rules are derived from the offsets Intl reports rather than read from a table.
samples/ADVANCED_USAGE.md covers the rest, with the measured cost of each.
java.io serialization writes the bytes a JVM writes, checked byte for byte against a real
ObjectOutputStream:
byte[] wire = Serial.encode(order);
Order back = Serial.decode(wire, Order.class);Generated at build time, including serialVersionUID computed the way the specification defines it,
so a stream written here is read by a JVM and the other way round. The generator refuses at build time
what the format cannot carry: a JDK collection field, a class with no no-argument constructor, and a
custom writeObject.
An npm package is JavaScript, so it never enters the WebAssembly. Declaring it puts it in the generated manifest and emits the static import that makes it resolvable β the package name otherwise lives in a wasm custom section that no bundler can follow.
bytebox {
npm("nanoid", "^5.0.9")
npmBindings("nanoid")
}npmBindings reads the package's TypeScript declarations and writes the Java to call it, so the
functions arrive typed and documented instead of hand-written. A package with no types still binds:
the generator drops through published declarations, DefinitelyTyped, JSDoc, inference, the AST, and
runtime introspection, and reports which of those it landed on. The floor is the module itself as a
TSObject, which never fails.
Written by hand, the same call is three lines:
@JSBody(
params = "size",
imports = @JSBodyImport(alias = "nanoid", fromModule = "nanoid"),
script = "return nanoid.nanoid(size);"
)
private static native String id(int size);Cloudflare meters the uncompressed bundle against 64 MiB on either plan. Nothing is enforced against the gzip figure wrangler prints beside it, and a Java Worker sits far under either. What a Worker has to fit is the one second it gets to parse and compile its module before the first request.
Measured on deployed Workers, that cost tracks compiled code and not bundle size. A hello world starts in 8 to 11 ms. Half a megabyte of real compiled Java on top of it costs about 7 ms. Padding the same Worker with half a megabyte of inert bytes instead costs nothing measurable, even though it uploads the same number of bytes and transfers almost three times as many.
The module ships as raw bytes with no decompressor, so startup pays for compiling it and nothing else.
Reflection, collections and streams are cheap. java.net.http is the most expensive feature here,
because Duration pulls java.time in behind it. java.util.regex and String.format are cheap
only because the retargeting below answers both from the platform's own engines; linked from the
class library they are among the largest things a program can reach. Every feature is priced in the
technical report.
sizeReport prints the module against the meter and against your budget. size { budget = "250KiB" }
fails the build past a figure of your choosing.
Cloudflare Workers run in a single thread and the Web Worker API is unavailable, so there is no parallelism to be had. A Java thread is a fiber scheduled on the host's queue.
That makes a blocking API the honest one. env calls look synchronous and suspend underneath, which
is what a compiled continuation gives you. Future is the escape hatch when work needs to overlap or
outlive a response β java.util.concurrent.CompletableFuture does not exist on this platform, and a
thread pool cannot.
Workers freeze the clock between I/O, so a Thread.sleep would never come due. bytebox advances its
own clock to cover one and gives the compiled program the same clock to read, which is what makes a
sleep terminate. Absolute timestamps are sound; a duration taken from two readings is not, and no
CPU meter is readable from inside a request.
| Limit | Free | Paid |
|---|---|---|
| Worker size, uncompressed | 64 MiB | 64 MiB |
| Startup | 1 second | 1 second |
| CPU per request | 10 ms | 5 min |
| Memory per isolate | 128 MB | 128 MB |
| Subrequests per request | 50 | 10,000 |
| Cron Triggers per account | 5 | 250 |
- Subprocesses.
ProcessandProcessBuilderdo not exist, and Workers have no equivalent. - Threads. Fibers are real; parallelism is not.
- Dynamic class loading. Compilation is closed-world, so reflection resolves against classes registered at build time.
- Listening.
ServerSocketandDatagramSocketare left unresolved, because the platform accepts no inbound connection and speaks no UDP. %tand%a, atomic groups, possessive quantifiers. Anything a retargeted API cannot render exactly is refused where you can see it rather than approximated where you cannot.