Sitemap
Giridhar

Giridhar

gql-argo: Argo Binary Wire Format for Spring GraphQL

15 min readAug 9, 2026

--

Press enter or click to view image in full size

Code: github.com/giridhargg/spring_argo_gql

Maven Artifacts: Server starter, Client starter, Argo Core

TL;DR

gql-argo is a pair of Spring Boot starters (gql-argo-server-starter, gql-argo-client-starter) plus a shared, framework-agnostic core (gql-argo-core) that brings the Argo binary serialization format to Spring GraphQL. It turns your existing @QueryMapping/@SchemaMapping resolvers into a binary-over-HTTP API with zero changes to your GraphQL schema or resolver code — clients that ask for Accept: application/argo get compact binary responses; everyone else keeps getting normal JSON.

Highlights:

  • Correctly handles unions, interfaces, fragments, @skip/@include, and aliases — not just the easy scalar cases.
  • Distinguishes omitted, null, and error as three separate wire states, never conflated.
  • Apollo-Automatic-Persisted-Queries-style dynamic query registration: call client.document(rawQueryText) with zero explicit registration and no startup-time network calls.
  • Self-healing schema-evolution protection: if your GraphQL schema changes while a persisted wire schema is cached, the library detects it and re-derives automatically instead of silently encoding wrong data.
  • A pluggable scalar-codec SPI with ready-made codecs for Instant, OffsetDateTime, UUID, and Long.

The problem: GraphQL responses are verbose, and that cost is paid on every request

GraphQL’s JSON response format repeats every field name for every object in a list. A response with a thousand User objects repeats the strings "id", "name", "email" a thousand times each, even though the shape of every one of those objects is identical and already known the moment the query was parsed. Compression (gzip/br) recovers some of this, but it's recovering redundancy that a schema-aware binary format never introduces in the first place — and the CPU cost of compressing/decompressing is itself non-trivial at scale.

Persisted queries are the first half of the answer: instead of sending full query text on every request, the client sends a hash, and the server looks up the actual query text it already knows about. This is the same idea Apollo’s Automatic Persisted Queries (APQ) protocol uses, and it’s well-trodden ground.

The second half — replacing the response encoding itself with something more compact than JSON — is much less common in the wild, and is where this library lives.

Enter Argo

Argo is a formally specified binary serialization format for GraphQL, presented at GraphQLConf 2023 (“Argo: Designing a Compact and Compressible Binary Serialization Format for GraphQL,” Mike Solomon) and used in production at WhatsApp and Beeper. Argo is designed specifically around GraphQL’s own semantics, rather than bolting a generic message-oriented binary format onto GraphQL responses as an afterthought:

  • Schema-per-query, not schema-per-type. Argo derives a Wire Schema from the exact query text (fields selected, fragments, directives) rather than trying to reuse the whole GraphQL schema — so the wire encoding is exactly as wide as what the query actually asked for, nothing more.
  • Unions, interfaces, and fragments are first-class, not an afterthought bolted onto a message-oriented format that was never designed for polymorphism.
  • Null, absent, and error are three distinct states at the wire level — a field that wasn’t selected due to a fragment condition, a field that resolved to null, and a field that errored are all represented differently, so nothing is lost in translation.
  • Columnar blocks. Every value of a given scalar type (String, Int, a custom scalar's own name) across the entire response is written into one contiguous buffer, rather than interleaved with structural bytes — this is what lets general-purpose compression exploit type-homogeneous runs and beat compressed JSON, not just uncompressed JSON.

Despite being formally specified and production-proven, there wasn’t a readily available, well-tested implementation of Argo for the Spring GraphQL ecosystem. gql-argo is that implementation.

Architecture

Three modules, cleanly separated by responsibility:

Press enter or click to view image in full size

Nothing in your resolver code changes. ArgoGraphQlInterceptor installs as an ordinary WebGraphQlInterceptor that runs after your existing resolvers — it doesn't replace Spring GraphQL's execution pipeline, it observes the tail end of it.

Why not just add an HttpMessageConverter?

The obvious approach — register a custom HttpMessageConverter/WebFlux Encoder for application/argo and let Spring's normal content-negotiation machinery pick it up — doesn't work. Spring GraphQL's GraphQlHttpHandler.selectResponseMediaType() hardcodes the acceptable response media types to {application/graphql-response+json, application/json, application/graphql} and pins the response Content-Type to one of those before any converter/encoder is ever selected. ResponseBodyAdvice doesn't help either, since Spring GraphQL uses a RouterFunction, not @ResponseBody controller methods.

The fix: ArgoGraphQlHttpHandler subclasses Spring GraphQL's own GraphQlHttpHandler (and its WebFlux equivalent) and overrides prepareResponse(), which runs before the media-type pinning happens. For an Argo-accepting request it reads the already-encoded bytes straight off ExecutionResult.getExtensions() and builds the binary response directly; every other request is delegated untouched to super.prepareResponse(). One real consequence: only one GraphQlHttpHandler bean can exist per Spring context, so this can't coexist with another library that also registers a custom one in the same app.

The wire format

[Header]  [Block₁ length+data]  [Block₂ length+data]  ...  [Core length+data]
  • Header — a 7-bit flag bitset (inline-everything, self-describing, out-of-band field errors, self-describing errors, null-terminated strings, no-deduplication, user flags).
  • Blocks — one contiguous buffer per GraphQL scalar type name. STRING/BYTES blocks additionally deduplicate repeated values via backreference labels, so a response with a thousand "ACTIVE" enum values only writes that string once.
  • Core — the structural walk: signed zigzag-varint Labels with reserved meanings (-1 null, -2 absent, -3 error, ≤-4 a backreference id, otherwise a length or scalar value), plus whatever wasn't routed into a block.

Whether a NullableWireType wrapper needs its own explicit null/non-null marker, or can reuse the inner type's own label slot, depends on whether that inner type is "Labeled" (STRING, BYTES, ARRAY, BOOLEAN, VARINT, DESC, any nullable) or "Unlabeled" (FLOAT64, FIXED, non-nullable RECORD).

Unlike a hand-rolled envelope wrapping an otherwise-opaque binary blob, Argo treats the entire response (data/errors/extensions) as itself one schema-typed value, walked through the same Core+Blocks machinery — a field-level error nullifies that field and writes an inline error label plus error record right at that position (an OutOfBandFieldErrors header bit moves them to a top-level array instead, if you prefer that shape).

Getting started — server side

implementation 'io.github.giridhargg:gql-argo-server-starter:1.0.0'
gql-argo.server.register-endpoint=/argo/pq/register
gql-argo.server.schema-endpoint=/argo/pq/schema
gql-argo.server.list-endpoint=/argo/pq/list
gql-argo.server.accept-header=application/argo
gql-argo.server.expose-schema-endpoint=true # set false in production if clients are pre-warmed another way
gql-argo.server.allow-dynamic-query-registration=true # see "Dynamic queries" below

That’s it — your existing resolvers are untouched:

@Controller
public class UserResolver {
@QueryMapping
public User user(@Argument String id) { ... }
}

Requests with Accept: application/argo now get binary responses. Everything else keeps getting normal JSON, so existing clients are completely unaffected.

Getting started — client side

implementation 'io.github.giridhargg:gql-argo-client-starter:1.0.0'
gql-argo.client.server-url=http://my-graphql-server:8080
gql-argo.client.accept-header=application/argo
@Service
public class UserService {

@Autowired private ArgoGraphQlClient client;

private static final String GET_USER_QUERY = """
query GetUser($id: ID!) {
user(id: $id) { id name email age active }
}
""";

public void getUser(String id) {
ClientGraphQlResponse response = client.document(GET_USER_QUERY)
.variable("id", id).executeSync();

if (!response.isValid()) {
response.getErrors().forEach(e -> log.error("GraphQL error: {}", e.getMessage()));
return;
}
System.out.println("User: " + response.field("user").toEntity(User.class));
}
}

ArgoGraphQlClient is a plain, standard-compliant WebGraphQlClient — every method you already know from HttpGraphQlClient (.document(...), .retrieve(path).toEntity(Class), .retrieveSync(path), full ClientGraphQlResponse inspection) works identically, just backed by Argo binary encoding underneath. A parallel ReactiveArgoTransport is auto-configured whenever a WebClient bean is present, and the same client bean becomes genuinely non-blocking end-to-end.

Calling .document(rawText) directly, with no explicit registration step, is the recommended default and what's shown above — not a fallback. Named, ahead-of-time registration via ArgoPersistedQueryRegistry is still fully supported for anyone who wants it, but it's the secondary path now; see the feature spotlight right below for why, and for what to weigh before reaching for it instead.

Feature spotlight: dynamic (raw/unregistered) queries — no register() call needed

Prefer named, ahead-of-time registration? ArgoPersistedQueryRegistry supports that directly:

@Autowired private ArgoPersistedQueryRegistry registry;

@PostConstruct
public void registerQueries() {
registry.register("GetUser", GET_USER_QUERY);
}

// later, same fluent style as client.document(...) above
ClientGraphQlResponse response = registry.requestSpec(client, "GetUser")
.variable("id", id).executeSync();

For most apps, though, client.document(rawText) — the pattern already shown above — is the better default, because it needs none of that setup. This library deliberately ships no classpath-scanning ApplicationRunner that auto-registers .graphql files at startup, because that would mean a network call during application startup: an unreachable server during a routine rolling deploy would then prevent your whole app from starting — a real production risk, not just a style nitpick. ArgoGraphQlClient instead accepts a raw query document directly, exactly like the standard GraphQlClient API, with zero startup-time network dependency — the client.document(GET_USER_QUERY) call already shown above.

Get Giridhar’s stories in your inbox

Join Medium for free to get updates from this writer.

One more concrete reason to reach for .document(...) over named registration: a named/registered query only ever caches hash+schema client-side, not the query text, so it can't self-heal the way a raw/dynamic query can if the server-side schema changes later — see "schema-evolution self-healing" below.

Under the hood, ArgoGraphQlTransport runs an Apollo-Automatic-Persisted-Queries-style protocol every time you call .document(rawText), entirely as a black box the caller never has to think about:

  • First call for a given query text: the client hashes the query locally (SHA-256, the same algorithm the server uses), sends hash and text together, and the server derives the wire schema, registers it, and Argo-encodes the response inline. The client fetches that schema via the existing GET /argo/pq/schema/{hash} endpoint and caches it — two round trips total, the same cost as the pre-existing explicit register() flow.
  • Every subsequent call for that same text: hash-only, one round trip — identical wire cost to the already-registered/named flow.
  • Self-healing: if a steady-state hash-only call unexpectedly gets a PersistedQueryNotFound JSON response back (e.g. the server-side cache was evicted), and the client still has the original query text, it transparently retries once with hash+text.

This closes the gap between “persisted queries” and “raw/dynamic queries” almost entirely — from the caller’s point of view there’s no meaningful difference, and no “register every query at application startup” anti-pattern (a network call at startup time) to design around.

Client-side storage (hashes and schemas) is pluggable, mirroring the server-side SPI pattern:

public interface ArgoClientQueryStore {

void saveHash(String name, String hash);

Optional<String> findHash(String name);

void saveSchema(String hash, ArgoWireSchema schema);

Optional<ArgoWireSchema> findSchema(String hash);

Map<String, String> findAllHashes();
}

@Bean
public ArgoClientQueryStore boundedArgoClientQueryStore() {
return new CaffeineArgoClientQueryStore(Caffeine.newBuilder().maximumSize(500).build());
}

The default is an unbounded, in-memory ConcurrentHashMap-backed store — fine for most apps, but swappable for Redis/Caffeine-with-eviction/whatever fits your deployment.

Server operators who only want to serve pre-vetted queries (public-facing gateways, say) can disable this with gql-argo.server.allow-dynamic-query-registration=false — a raw query then fails with a clear error instead of being silently accepted.

Feature spotlight: schema-evolution self-healing

Here’s a subtle correctness problem that’s easy to miss: a persisted query’s wire schema is derived once, at registration time, and cached from then on (both server- and client-side). What happens when your GraphQL schema changes later — a field’s type changes, a field gets removed or renamed — while a persistent server-side store (Redis, say) still holds the old derived schema for a hash from before the change?

Without protection, the server would keep encoding against the stale schema. Some mismatches happen to throw and gracefully degrade to JSON (a safety net that already existed), but others — a field renamed while staying nullable, or a coincidentally cast-compatible type change — would silently encode wrong data with no error at all.

The fix: WireSchemaDeriver computes a fingerprint of the live GraphQL schema once per instance (a SHA-256 hash of SchemaPrinter.print(schema)) and stamps it onto every derived ArgoWireSchema. Before using a cached schema to encode a response, the library checks that fingerprint against the current one. On a mismatch, it rejects that lookup — forcing the exact same, already-tested "not found" fallback path a genuinely unregistered hash would hit — while re-deriving and re-saving the fresh schema in the background, so the very next lookup for that hash succeeds.

The elegant part: zero client-side changes were needed. The dynamic-query recovery mechanism described above already knows exactly how to react to a “not found” signal:

  • A raw/dynamically-registered query (client still has the text cached) recovers transparently — no visible disruption at all.
  • A named query registered via register() (client only ever caches hash+schema, not the original text) fails loudly with a clear error, and needs an app restart to re-register. This is a deliberate choice: a loud, actionable failure beats silent data corruption every time.

The in-memory default store sidesteps this entire class of problem by construction — it’s wiped on every restart, so it can never hold a schema derived against a previous GraphQL schema generation. This only matters once you plug in your own persistent ArgoPersistedQueryStore.

Feature spotlight: scalar codec SPI

By default, any custom scalar (a DateTime, a UUID, ...) falls back to a STRING wire type and toString() — safe, always correct, but not especially compact. Register an ArgoScalarCodec bean, keyed by the scalar's own GraphQL type name, to pick a tighter wire representation instead:

@Bean
public ArgoScalarCodec dateTimeArgoScalarCodec() {
return new ArgoScalarCodec() {
public String scalarName() { return "DateTime"; }
public WireType.ScalarKind wireKind() { return WireType.ScalarKind.VARINT; } // epoch millis
public Object toWireValue(Object domainValue) { return ((Instant) domainValue).toEpochMilli(); }
public Object fromWireValue(Object wireValue) { return Instant.ofEpochMilli((Long) wireValue); }
};
}

You rarely need to hand-write one, though — gql-argo-core ships ready-made codecs for the most common shapes:

Press enter or click to view image in full size
@Bean
public ArgoScalarCodec dateTimeArgoScalarCodec() {
return new InstantArgoScalarCodec(); // defaults to scalar name "DateTime"
}

Register the same codec (matched by scalarName()) on both the server and client app — they don't have to be the same Java class, just agree on the wire representation.

A gotcha worth knowing: toWireValue()/fromWireValue() operate on whatever your scalar's own Coercing.serialize() returns during normal GraphQL execution, which runs before the codec ever sees the value. If your Coercing.serialize() stringifies the value, the codec receives a String instead of the domain object it expects, throws, and the interceptor's fallback-to-JSON safety net silently swallows it — confusing symptom, simple fix: make serialize() return the domain value unchanged (identity).

Subscriptions

GraphQL-over-WebSocket subscriptions are real and tested: ArgoGraphQlInterceptor detects when ExecutionResult.getData() is a Publisher<ExecutionResult> (a subscription's event stream rather than a resolved value) and Argo-encodes each streamed event individually, nulling out the event's own JSON data/errors so the WebSocket frame doesn't carry a redundant plain-JSON copy alongside the binary payload. Client-side, ArgoWebSocketGraphQlClient/ ArgoWebSocketGraphQlTransport provide the matching subscription-capable client. Note: dynamic (unregistered) query registration doesn't extend to subscriptions — a subscription still needs register()/registerHash() ahead of time.

Errors and partial success

GraphQL’s message/locations/path/extensions error shape is fully preserved on the wire, including arbitrary custom extensions content (via a self-describing encoding, since that shape isn't knowable from the schema ahead of time). Every encoded response carries an explicit status alongside the bytes — OK, PARTIAL (data and errors coexisting, per GraphQL semantics), or ERROR — so a client can tell at a glance which case it's looking at without inspecting the payload first.

How much of the GraphQL spec does this actually cover?

Rather than just claim completeness, this library ships a full feature-by-feature audit, checked against the actual implementation, not just the docs. The short version:

Solidly covered and tested: all core value kinds (scalars, objects, lists, non-null/null), built-in scalars, the omitted/null/error three-way distinction, arbitrarily large lists (no recursion-depth risk from element count), interfaces and unions (via field-merging with __typename support), aliases, inline and named fragments, @skip/@include, error shape preservation, partial success, real subscriptions, and introspection (__schema/__type).

Partially covered: custom scalars (safe STRING fallback everywhere, compact ready-made codecs for the most common shapes) and enums (STRING-encoded with automatic deduplication, not integer ordinals).

Deliberately out of scope: binary request encoding (this is a response-serialization format, matching the actual Argo spec’s own scope), @defer/@stream incremental delivery, batch requests, file uploads, and Apollo Federation — none of these have a concrete driving use case in this project yet, and building them speculatively risks locking in the wrong shape before real usage reveals what's actually needed.

Benchmarks: is it actually smaller and faster?

The claims above (“beats compressed JSON, not just uncompressed JSON”) are checked, not just asserted. Two separate benchmarks, since they answer different questions:

Wire size — no timing involved, just byte lengths for JSON vs. Argo, raw and gzip-compressed, across seven representative payload shapes:

Press enter or click to view image in full size

Two honest findings, not just a clean win: for a genuinely tiny payload, gzip’s own ~18–20 byte fixed overhead means compression itself isn’t worth it for either format (notice JSON+gzip is larger than plain JSON there) — Argo’s raw-byte advantage is what matters at that size, not the compressed one. And the margin narrows substantially for large flat lists (50.8%→49.3% smaller uncompressed as the list grows from 100 to 100,000 rows, but only ~15% smaller compressed) — gzip already recovers most of the “repeated field name” redundancy Argo’s columnar blocks also target, so compression and Argo’s design are attacking the same waste from two different angles, not stacking additively. Nesting tells the opposite story: the margin widens as nesting gets bigger (28.6% → 58.2% smaller compressed going from 50 to 2,000 orders) — gzip’s sliding window struggles more with repetition spread across nested structure than Argo’s per-type columnar blocks do, so the two approaches diverge instead of converging the way they do for flat lists.

Encode/decode CPU cost — average time per call via JMH, lower is better:

Press enter or click to view image in full size

(noisy — a wide confidence interval relative to the mean; read those cells directionally, not as tight numbers.)

Decoding is a clean, consistent win for Argo at small-to-medium scale — 15–43% faster than Jackson from a single record up through 2,000 nested orders — unsurprising since it skips string parsing/escaping entirely. That win gets genuinely uncertain at the largest scale tested (100,000 flat records), where Argo decode actually measured slower than Jackson deserialize — but both sides of that comparison carry enough noise that it reads as “needs a longer, quieter run to trust,” not as an established reversal.

Encoding is a genuine, honest tradeoff, not a clean win, and the shape matters more than “flat vs. nested” alone suggests: for flat homogeneous lists (100 through 100,000 rows), Argo encode is consistently slower than Jackson serialize — the per-value columnar-block bookkeeping (separate block writers per scalar type, STRING dedup hashing, varint zigzag math) costs more than Jackson’s straightforward string-append serialization for that shape. For the broad-but-shallow nested shape (orders with items), Argo encode flips to a clear win (23–34% faster), since Jackson’s generic reflection/traversal overhead scales worse there. But the deeply nested shape (4 levels: Company → Department → Team → Employee) doesn’t get the same win — Argo encode measured ~10% slower than Jackson there too, despite being nested. It turns out to be fan-out breadth (many sibling records hanging off one field) that favors Argo’s encode path, not nesting depth by itself — the company/department/team/employee tree has only 1,250 total leaf employee records, fewer than either flat-list shape, despite “looking” like the more complex payload.

Is deduplication worth its own overhead? One more experiment worth mentioning: the wire format always turns on backreference deduplication for STRING blocks — free when values actually repeat (an enum column, a status field), but for a 10,000-row dataset where every id/name/ email value is unique, dedup's hashing/lookup bookkeeping never saves a single byte. Encoding that exact data with dedup on vs. off measured roughly 72% more encode time for zero space savings on entirely-unique data — a concrete, measured argument for making that behavior configurable per field down the road, not just a hypothetical one.

Run these yourself if you want to see it on your own hardware: the wire-size comparison is a plain JUnit test with no timing dependencies, and the CPU-cost comparison is a JMH module (takes somewhere in the 10–20 minute range depending on machine). The HTTP transport layer itself was deliberately left out of scope for both benchmarks, since that’s Spring’s RestClient/ WebClient/RestTemplate code — already benchmarked far more rigorously upstream than anything specific to this library would add.

Try it yourself

The repo ships two complete, runnable Spring Boot applications wired end-to-end — interface/union types, a custom DateTime scalar, and every transport variant (RestClient, WebClient blocking and reactive, RestTemplate) side by side — so you can see the whole thing working before committing to it.

Code: github.com/giridhargg/spring_argo_gql

Maven Artifacts: Server starter, Client starter, Argo Core

If you’re running Spring GraphQL at any real scale — high fan-out, high request volume, or just tired of gzip fighting the same battle your JSON already lost — give it a try and let me know what you find.

--

--