DEV Community

Hongxi
Hongxi

Posted on

I Dropped the gRPC Dependency and Hand-Wrote the Wire Format. grpcurl Still Works.

TL;DR: My RPC framework's jaws-wire module speaks the standard gRPC wire format — 5-byte length-prefixed frames over HTTP/2, status in trailers — with zero dependency on grpc-java. A stock grpc-java client and grpcurl can call it, and it can call a stock gRPC server. The whole module is 4,604 lines, and its only dependencies are the framework core and protobuf-java.


Why would anyone do this?

JAWS (Java Async Wire Service) is a ~23K-line RPC framework built for one purpose: you can read it end-to-end. Read the whole core, understand how an industrial RPC actually works, then go read Dubbo ten times faster.

That mission has a corollary: every layer must be ours to read. The moment we wanted gRPC interoperability, the default answer — "add grpc-java, it's right there" — was wrong for this project. grpc-java is a magnificent library, but it would become an opaque wall exactly where the interesting part is: how gRPC actually rides on HTTP/2.

So the rule became: speak the protocol, don't marry the library. Use protobuf-java for message encoding (no reason to re-derive varint), and hand-write everything above it: the framing, the HTTP/2 mapping, trailers, status codes, deadlines, keepalive, compression.

What follows is what the wire format actually looks like when you strip the library away.

The gRPC wire format is smaller than you think

gRPC over HTTP/2 is famously "just" a convention. Famous, yet rarely seen with your own eyes. Here is the entire message framing, from WireFrameCodec:

// Each gRPC message on the wire is framed as:
//   [1 byte compressed-flag] [4 bytes big-endian length] [payload bytes]
Enter fullscreen mode Exit fullscreen mode

That's it. One byte saying whether the payload is compressed, four bytes of length, then raw protobuf bytes. Everything else in the protocol is HTTP/2 itself:

  • Request: HEADERS frame with pseudo-headers :method POST, :path /Service/Method, content-type: application/grpc, plus optional grpc-encoding, grpc-timeout, and your custom metadata — followed by DATA frames carrying length-prefixed messages.
  • Response: HEADERS with :status 200 and content-type, then DATA frames with the reply, then a final HEADERS frame — the trailers — carrying grpc-status: 0 and optionally grpc-message.

The "status in trailers" trick is the part that surprises people. gRPC does not put its status code in the HTTP status. An RPC that failed with NOT_FOUND travels over HTTP/2 with :status 200 — the transport succeeded — and the application status rides in trailing headers after the last DATA frame. If an error occurs before any message is sent, the server may collapse everything into a single "trailers-only" response: status, content-type, and grpc-status in one HEADERS frame.

Once you see this shape, the whole protocol stops being magic. It's:

HEADERS (method, path, metadata)
DATA    [flag|length|protobuf]
DATA    [flag|length|protobuf]   ← streaming = more frames
HEADERS (grpc-status, grpc-message)  ← trailers, always present
Enter fullscreen mode Exit fullscreen mode

The server: Netty HTTP/2, no stubs

On the server side, WireServer starts a Netty HTTP/2 server and routes each stream by its :path. Business logic is registered per method, not code-generated:

WireHandlerRegistry registry = new WireHandlerRegistry();
registry.register("interop.Greeter", "SayHello", new WireMethodHandler() {
    @Override
    public Message handle(Message request, WireCallContext context) {
        HelloRequest req = (HelloRequest) request;
        String traceId = context.getAttachment("x-trace-id"); // from gRPC metadata
        return HelloReply.newBuilder()
                .setMessage("Hello, " + req.getName() + "! (from jaws-wire)")
                .build();
    }

    @Override
    public Parser<? extends Message> getRequestParser() {
        return HelloRequest.parser();
    }
});
Enter fullscreen mode Exit fullscreen mode

No protoc plugin, no generated GreeterGrpc.GreeterImplBase, no StreamObserver boilerplate. You parse what you declare, you return what you build. The handler contract has exactly two jobs.

For server streaming, the handler returns a Flow.Publisher<Message> — the JDK 9+ reactive-streams interface — instead of taking a callback:

registry.register("interop.Greeter", "SayHelloStream", new WireMethodHandler() {
    @Override
    public MethodType methodType() {
        return MethodType.SERVER_STREAMING;
    }

    @Override
    public Flow.Publisher<Message> handleStream(Message request, WireCallContext context) {
        HelloRequest req = (HelloRequest) request;
        return subscriber -> {
            subscriber.onSubscribe(new Flow.Subscription() {
                @Override
                public void request(long n) {
                    for (int i = 1; i <= 3; i++) {
                        subscriber.onNext(HelloReply.newBuilder()
                                .setMessage("Hello #" + i + ", " + req.getName() + "!")
                                .build());
                    }
                    subscriber.onComplete();
                }
                // ...
            });
        };
    }
    // ...
});
Enter fullscreen mode Exit fullscreen mode

I chose Flow.Publisher deliberately: it's in the JDK, it composes, and it makes backpressure an explicit part of the contract rather than something a generated stub hides from you.

One detail I'm fond of: WireServer automatically registers the standard grpc.health.v1.Health service, hand-written against the vendor proto without protoc. So Kubernetes gRPC health probes and grpcurl health checks work out of the box.

Status codes: where semantics live or die

Here's the part where "protocol-compatible" projects usually quietly fail. If your error mapping is lazy — every error becomes INTERNAL — standard clients degrade: they won't retry calls they should retry, and they'll misreport deadline misses. The gRPC status code is the contract.

So WireStatus maintains a bidirectional, semantically honest mapping:

// Mapping rules (jaws → gRPC):
//   SERVICE_TIMEOUT (40003)      → DEADLINE_EXCEEDED (4)
//   Connection/transport failure → UNAVAILABLE (14) — retryable
//   Business exceptions          → UNKNOWN
//   Service not found            → NOT_FOUND
Enter fullscreen mode Exit fullscreen mode

And the reverse direction on the client side, so that when JAWS calls a gRPC server and gets UNAVAILABLE, it knows that failure is retryable — the failover cluster can pick another node. The timeout mapping carries grpc-timeout end-to-end — the client's deadline travels in the header, the server applies it to its dispatch future — and I verified it the honest way: give grpcurl a deadline tighter than the streaming method takes to finish, and it reports the miss exactly as it would against grpc-java:

$ grpcurl -plaintext -max-time 0.15 \
    -import-path <proto-dir> -proto greeter.proto \
    localhost:50051 greeter.Greeter/SayHelloStream
ERROR:
  Code: DeadlineExceeded
  Message: context deadline exceeded
Enter fullscreen mode Exit fullscreen mode

That's grpcurl — a real gRPC client that knows nothing about my framework — agreeing with my server on deadline semantics. Not a stub echo. A standards tool testifying.

The proof: two directions, both ways

Interop claims need heterogeneity. Testing my client against my server proves nothing — reflection hides naming mismatches from the same codebase. So the jaws-sample-wire-interop module demonstrates both directions against real gRPC:

Direction 1 — grpc-java client → JAWS server. A stock ManagedChannel with MetadataUtils attaches x-trace-id; the server reads it from WireCallContext and echoes it back. Unary and server-streaming both work, metadata flows end-to-end through HTTP/2 headers.

Direction 2 — JAWS client → grpc-java server. WireClient connects to a standard gRPC server (started with the actual grpc-java API) and calls its methods with full filter-chain support — load balancing, auth, metrics all apply, because jaws-wire is a first-class protocol inside the framework, not a bolt-on bridge.

Run the interop demos yourself:

./mvnw -q compile exec:java -pl jaws-samples/jaws-sample-wire-interop -am \
    -Dexec.mainClass="org.hongxi.jaws.sample.wire.interop.GrpcCallWireDemo"
Enter fullscreen mode Exit fullscreen mode

Or start the plain wire sample and poke it with grpcurl (no server reflection — you point grpcurl at the proto file, just like with any gRPC server that doesn't enable reflection):

./run-sample.sh wire        # starts a JAWS server on :50051 speaking gRPC wire format
grpcurl -plaintext -import-path <proto-dir> -proto greeter.proto \
    -d '{"name": "grpcurl"}' localhost:50051 greeter.Greeter/SayHello
Enter fullscreen mode Exit fullscreen mode

What the protocol taught me

Hand-writing the wire format converted gRPC from "a library I use" into "a protocol I understand." A few things that only became real when I had to write them:

Trailers force async thinking. You cannot write grpc-status until the RPC's fate is known, which means your response path must be structured around completion — a whenComplete, not a return. This shaped the entire async dispatch pipeline in the framework core.

Metadata is just headers, and headers need rules. gRPC metadata maps to HTTP/2 headers, but only some of them: custom keys must be lowercase and (for ASCII values) [a-z0-9_-.], and the -bin suffix has a special meaning for binary values. Getting the mapping bidirectionally right — including which headers to strip on the way in and which to add on the way out — is fiddly, protocol-grade work.

The features you thought were "gRPC" are mostly policies. Keepalive PING strategy, too_many_pings GOAWAY, gzip content coding, RST_STREAM as cancellation, max message size limits — none of these are in the framing. They're HTTP/2 mechanics applied with gRPC's chosen policies. Implementing each one was a focused lesson in a different corner of the HTTP/2 spec.

The ledger

What does the whole thing cost? The jaws-wire module is 4,604 lines — frame codec, HTTP/2 server and client handlers, status mapping, compression, health service, streaming glue — with exactly two dependencies: the framework core and protobuf-java. Compare that to grpc-java's core alone, and remember that this includes no code generation step for your services.

I won't pretend this replaces grpc-java for production use — it doesn't aim to. There is no proxy support, no load-balancing delegation to an external name resolver, none of the hardening that comes from a decade of production fires. What it replaces is ignorance. Every one of those 4,604 lines is readable in an afternoon, and together they form a complete, working skeleton of how gRPC actually works.

That was the goal. The framework is called "the RPC skeleton you can read end-to-end," and now the gRPC wire format is part of what you get to read.


Try it: github.com/javahongxi/jaws./run-sample.sh wire is one command away from a gRPC-compatible server on your machine. The interop module shows both directions against stock gRPC. Stars and issues welcome, in any language.

Cover image: the JAWS poster — a shark swimming through circuitry. Because "JAWS".

Top comments (0)