Why Language Boundaries Break Polyglot Microservices
Polyglot microservices break at the seams, not inside services. The 5 cross-language failure modes (deadlines, cancellation, errors, types, connections) and fixes.
Part of Polyglot Microservices: Choosing the Right Language
Polyglot microservices fail at the seams, not inside the services. Each language brings its own idea of what a deadline means, how an error propagates, what a null is, and when a connection is dead.
The bugs that cost you a weekend live in the gap between a Go service and a Rust one, or a Java one and a Python one, where two runtimes quietly disagree about the same contract. Naming those five failure modes is how you stop paying for them in 3 a.m. incidents.
Why polyglot microservices fail more than single-language systems
Polyglot microservices fail more often because each language ships its own defaults for deadlines, cancellation, errors, serialization, and connections. A single-language system shares those defaults for free; a polyglot system has to make every cross-language contract explicit, and the bugs live in the gaps where two runtimes disagree.
A single-language system has one set of defaults. Everyone shares the same timeout semantics, the same error model, the same serialization quirks. The contract is enforced by the language for free.
A polyglot system has several sets of defaults that all look compatible until traffic finds the corner where they are not. The Protobuf says int64, and Go, Java, and JavaScript each handle large integers differently. A deadline is set, and each gRPC implementation propagates and cancels it slightly differently.
These are not exotic edge cases. They are the daily tax of running several languages in one mesh, and they are why this post sits in the Language choices in polyglot microservices series. Here are the five that bite hardest.
Why do deadlines mean different things in each language?
Because a deadline is only a wall-clock instant on the wire; what each runtime does with the remaining budget on the next hop is an implementation choice, not a specification. gRPC guarantees the deadline travels. It does not guarantee every runtime subtracts elapsed time, enforces the remainder on downstream calls, or aborts local work when the budget is gone.
A deadline is a contract that says “stop working at this wall-clock time.” Every gRPC runtime claims to honor it. They honor it differently.
When a service sets a 200 ms deadline and calls a chain of downstream services, the remaining budget is supposed to propagate so each hop knows how much time is left. Whether it actually does depends on every service in the chain implementing deadline propagation, and language defaults are not uniform about it.
The failure looks like this: the client times out at 200 ms and returns an error to the user, but three services downstream are still doing work for a request nobody is waiting for. You are burning CPU on dead requests, and under load that wasted work is what tips you into cascading failure.
The fix is to treat deadline propagation as an explicit contract, not a default you hope is on. Set a deadline at the edge, propagate the remaining budget on every hop, and verify each language in your stack actually honors it under test.
Why does cancellation not cross a language boundary cleanly?
Related but distinct: when a caller gives up, the work it started should stop. Cancellation propagation is how that happens, and it fails differently in each runtime.
Go uses context.Context, and cancellation is cooperative, so code has to check ctx.Done() and return. Rust’s async cancellation drops the future, which runs destructors but can leave shared state mid-update. The JVM gRPC stack has its own cancellation signaling. These do not compose automatically across a boundary.
The result is orphaned work and, worse, partial state. A cancelled request that already wrote half its changes leaves the system in a state no single service author anticipated, because the inconsistency lives between services.
This is why idempotency and explicit cancellation handling matter more in polyglot systems than in single-language ones. You cannot trust the runtime to clean up across a boundary it does not own. Make every cross-service mutation idempotent so a retried-or-orphaned request cannot corrupt state.
The per-runtime differences are worth having in front of you, because the mitigation differs by language even though the contract does not:
| Runtime | Cancellation model | The trap at a boundary |
|---|---|---|
| Go | Cooperative via context.Context; code must check ctx.Done() | A tight loop or a blocking call that never checks the context keeps running after the caller has given up |
| Rust | Dropping the future cancels it; destructors run | Cancellation can land between two awaits, leaving shared state half-updated with no unwinding of the logical operation |
| Java | gRPC signals cancellation; thread interruption is advisory | Blocking I/O that swallows InterruptedException continues working on a dead request |
| Elixir/BEAM | Process death is the cancellation primitive; supervisors restart | Cheap and clean per-connection, but a process that already emitted a side effect cannot un-emit it |
| Python | asyncio task cancellation raises inside the coroutine | A broad except Exception swallows the cancellation and the task runs to completion |
Read that table as one conclusion: in every runtime, cancellation is best-effort and can land mid-operation. No language gives you transactional cancellation across a service boundary. That is why the durable fix is not better cancellation handling — it is designing so that a half-finished or repeated operation is harmless.
Two disciplines get you there, and both are laws rather than per-service choices in the architecture I run. Every consumer is idempotent by event ID, so a replayed or orphaned message converges to the same state instead of double-applying. And every producer writes through a transactional outbox, so a cancelled request cannot leave a database row committed with its corresponding event never published, or the reverse. The one documented exemption is ephemeral telemetry whose owning service holds no database state and whose stream is loss-tolerant — which is precisely the case where a lost or duplicated message costs nothing.
That is the general lesson for cross-language cancellation: stop trying to make the runtimes agree, and make the operations safe to repeat. See Idempotency Keys for Distributed Systems for the mechanics.
How do you handle errors across different languages in microservices?
Define the error contract in the Protobuf as explicit status codes plus structured error details, and treat each language’s native error type as an implementation detail that never crosses the wire. The boundary should speak status codes, not exceptions, and no service should parse error-message strings for logic.
Every language has an error philosophy, and the boundary is where philosophies collide. Go returns errors as values and expects you to check them. Rust has Result and panics. Java throws checked and unchecked exceptions. Python raises. gRPC gives you a status code and message to bridge them, and that bridge is lossy.
A rich Rust error with a typed cause chain becomes a gRPC status code and a string by the time the Go caller sees it. The Go service then has to reconstruct intent from a status enum and a message it must not parse for logic. Information is lost at every crossing, and the temptation to string-match error messages for control flow is a bug waiting to ship.
How does serialization disagree about your types across languages?
The Protobuf schema is supposed to be the single source of truth. It mostly is, until a type behaves differently on each side.
The classic is the 64-bit integer. Protobuf int64 is fine in Go and Java, but JavaScript’s number type cannot represent the full range, so a generated TypeScript client silently loses precision on large values. Enums are another trap: an unknown enum value from a newer schema is handled differently across runtimes, and the wrong default can mean a silent misroute.
Then there is the difference between a field that is absent, a field that is zero, and a field that is null. Proto3’s handling of presence, and how each language surfaces it, is a recurring source of “the value was there in the sender and gone in the receiver” bugs.
The mitigation is schema discipline: explicit field presence where it matters, conservative enum handling with a reserved UNKNOWN case as the zero value, and never relying on a default that differs across the languages you actually run.
The int64 problem, with the actual numbers
This one deserves specifics, because “JavaScript is bad at big numbers” is too vague to act on.
JavaScript numbers are IEEE-754 double-precision floats. They represent integers exactly only up to 2^53−1, which is 9,007,199,254,740,991. A Protobuf int64 goes to 2^63−1, or 9,223,372,036,854,775,807 — about a thousand times larger.
So a snowflake-style ID, a nanosecond timestamp, or any monotonic counter that crosses 9.007×10^15 will silently round in a generated JS or TypeScript client. Not error. Round. Two distinct IDs can compare equal, and a lookup returns the wrong record with no exception anywhere in the stack.
The rule that avoids this entirely: large identifiers cross the wire as string, not int64. You give up arithmetic on the field, which you almost never wanted on an ID anyway, and you get exact round-tripping in every language. Reserve int64 for values you genuinely do math on and that you can prove stay under 2^53, or use it only between services whose runtimes all handle it natively.
What does a cross-language boundary contract actually specify?
Here is the artifact I wish I had written before the second language shipped. Call it the Boundary Contract Matrix: five concerns, each with one decision that applies to every language, rather than five languages each with their own defaults.
| Concern | The contract to specify once | What breaks without it |
|---|---|---|
| Deadlines | Deadline set at the edge; every hop subtracts elapsed time and enforces the remainder; local work aborts when the budget is gone | Downstream services burn CPU on requests nobody is waiting for; wasted work tips you into cascading failure under load |
| Cancellation | Cancellation is signalled explicitly and every cross-service mutation is idempotent | Orphaned work and partial state that lives between services, which no single service author anticipated |
| Errors | Status codes plus structured error details in the Protobuf; native error types never cross the wire; no string parsing for control flow | Lossy translation at every hop; teams string-match error messages and ship logic that breaks on a copy edit |
| Serialization | Explicit field presence; reserved UNKNOWN enum zero value; large IDs as string | Silent precision loss, silent misroutes on unknown enums, “the value was there and then it wasn’t” bugs |
| Connections | One keepalive and connection-age policy, derived from one shared config, applied identically everywhere | Bursts of failures after idle periods or deploys; sticky HTTP/2 connections defeating load balancing |
The matrix is deliberately boring. That is the point: every row replaces a per-language default with one decision, and the languages become implementations rather than negotiators.
When is polyglot the wrong choice?
Everything above is the cost side of the ledger, and it is worth stating plainly that the cost is real and recurring. So the honest question is not “how do I survive polyglot” but “did I need it at all.”
Polyglot pays for itself when a specific domain has a requirement its runtime uniquely satisfies. Tail-latency-critical work where a garbage collector’s pause is unacceptable. Massive real-time concurrency with fault isolation per connection. Heavy stream processing where the mature tooling lives on one platform. In those cases the seam tax buys something you cannot get otherwise.
It does not pay for itself when the motivation is preference, résumé, or “this service felt like a good place to try the new language.” A useful test before adding language number three: name the requirement that language exists to satisfy, in one sentence, without using the word “better.” If you cannot, you are buying a permanent boundary tax for a one-time authoring convenience.
Two costs people underestimate. First, the boundary surface grows faster than the language count, because it is the pairs that interact. Second, on-call: every language in production is a language someone must debug at 3 a.m., with its own profiler, its own heap dump format, and its own failure idioms. A four-language stack is four sets of runbooks.
The architecture I run is deliberately polyglot — Go for stateless edge and orchestration, Java for transactional business domains, Elixir for real-time fan-out and presence, Rust for the latency-critical hot paths. Each of those four has a one-sentence justification of exactly the kind above. Nothing was chosen because it seemed interesting, and the count has stayed flat as services were added, because new services are placed into an existing runtime rather than justifying a new one.
What does a cross-language conformance test contain?
The contract is only real if something fails when a service violates it. A conformance suite is that something: one set of tests, run against every service regardless of language, exercising the behaviors Protobuf does not define.
At minimum it asserts:
- Deadline propagation. Call with a short deadline through a chain and assert every hop observed a decreasing remaining budget, and that work stopped when it hit zero.
- Cancellation. Cancel mid-flight and assert the downstream stopped, and that replaying the same request produces the same final state.
- Error mapping. Force each error class and assert the caller sees the agreed status code and structured detail, never a language-native type.
- Presence and enums. Send an absent field, an explicit zero, and an enum value the receiver’s schema version does not know. Assert all three are distinguishable and that the unknown enum lands on
UNKNOWNrather than a meaningful case. - Large integers. Round-trip a value above 2^53 and assert exact equality.
- Connection policy. Idle past the keepalive interval and assert the connection is still usable or cleanly re-established.
Run it in CI for every service. A new language entering the stack is not “supported” until it passes the suite, which turns a vague architectural principle into a merge gate.
Why do connection and keepalive defaults differ between runtimes?
A dead connection is a per-runtime opinion. Each gRPC implementation ships its own keepalive intervals, connection-age limits, and reconnect behavior, and they are not the same out of the box.
One service thinks the connection is alive and keeps sending. The other has already torn it down. You get a burst of failures on connections that “should” have been healthy, usually after an idle period or a deploy, and it looks intermittent because it depends on timing.
Load balancing makes it worse. gRPC multiplexes many requests over one HTTP/2 connection, so a client that holds a sticky connection to one backend pod will not spread load the way request-level balancing would, and the defaults for how that is handled vary by language.
The fix is to set keepalive and connection-age parameters explicitly and identically across every service, derived from one shared config, rather than inheriting several different defaults. The boundary should have one connection policy, not one per language.
The pattern: make the boundary explicit, not implicit
Every failure mode above has the same root cause and the same cure.
The root cause is relying on a default at a boundary where two runtimes have different defaults. The cure is to make the contract explicit and shared, so no service is guessing.
Concretely, that means one source of truth for deadlines and their propagation, one error contract in the schema, one set of serialization rules with presence handled explicitly, and one connection policy applied everywhere. The Protobuf and a shared config carry the contract; the languages just implement it.
This is more work than a single-language system, and it is the actual cost of going polyglot. The benefit, each service in the language suited to its job, is real. But you only keep it if you pay the seam tax deliberately instead of in incidents.
A boundary-hardening checklist
Run this before you let two languages talk in production.
- Deadline propagation is explicitly implemented and tested across the chain, not assumed.
- Cancellation behavior is documented per language, and shared-state mutations are idempotent.
- The error contract lives in the Protobuf as status codes and structured details; no service parses error strings for logic.
- Serialization edge cases are pinned: integer width, enum unknowns, field presence.
- Keepalive and connection-age parameters come from one shared config, identical across services.
- A cross-language integration test exercises the boundary itself, not just each service in isolation.
What I’d do differently
Early on, it is tempting to treat the Protobuf as sufficient, since it defines the messages, so surely the boundary is specified. It is not. The Protobuf specifies the shape of the data and says nothing about deadlines, cancellation, error semantics, or connection lifecycle, which is where the real disagreements live.
If I were building the contract layer again, I would write the cross-language behavior contract before the second language ever shipped: one document and one shared config covering deadlines, errors, presence, and connections, with a conformance test every service must pass. The cost of writing it up front is a week. The cost of discovering it incident by incident is a year. For the gRPC-specific version of this contract, see gRPC Across Languages: Production Lessons.
Sources
- gRPC documentation, Deadlines: grpc.io/docs/guides/deadlines
- Protocol Buffers, Proto3 language guide: protobuf.dev/programming-guides/proto3
- gRPC documentation, Keepalive: grpc.io/docs/guides/keepalive
Frequently asked questions
What is a polyglot microservices architecture?
A system where different services are written in different languages, each chosen to fit its job, communicating over a shared protocol like gRPC. The benefit is the right runtime per service; the cost is managing the boundaries between languages with different defaults.
Why do polyglot microservices fail more than single-language systems?
Because each language has its own defaults for deadlines, cancellation, error handling, serialization, and connections. A single-language system shares those defaults for free. A polyglot system has to make every cross-language contract explicit, and the bugs live in the gaps.
How do you handle errors across different languages in microservices?
Define the error contract in the Protobuf as explicit status codes and structured error details. Treat each language's native error type as an implementation detail that does not cross the wire, and never parse error message strings for control flow.
Is gRPC enough to guarantee compatibility across languages?
No. gRPC and Protobuf define message shapes and a transport, but not deadline-propagation behavior, cancellation semantics, error mapping, or connection policy. Those must be specified and tested separately as an explicit cross-language contract.
When should you not use polyglot microservices?
When no service has a requirement its runtime uniquely satisfies. Polyglot pays off only when a specific domain genuinely needs a different runtime, such as tail-latency-critical work or massive real-time concurrency. If the motivation is developer preference, stay single-language and keep the shared defaults you get for free.
How many languages is too many for a microservice architecture?
There is no fixed number, but each additional language multiplies the boundary surface you must specify, test, and staff on call. A useful test is whether you can name the specific requirement that language exists to satisfy. If you cannot, it is one language too many.
Why does Protobuf int64 lose precision in JavaScript clients?
JavaScript numbers are IEEE-754 doubles, which represent integers exactly only up to 2^53-1 (9,007,199,254,740,991). A Protobuf int64 can exceed that, so values above the limit silently round in a generated JS or TypeScript client. The fix is to carry large identifiers as strings on the wire rather than as int64.
What is a cross-language conformance test?
A shared test suite every service must pass regardless of language, which exercises the boundary behaviors that Protobuf does not define: deadline propagation, cancellation, error mapping, field presence, and connection policy. It is the only reliable way to prove that runtimes agree.