Skip to main content

Eureka Promises Load Balancing. gRPC Has Never Heard of Eureka.

M
Markian MumbainTech blog
7 min read·Mar 21, 2026
Name ResolverEurekaRound Robin
The Potato Eaters
The Potato Eaters, 1885,Vincent van Gogh

Over-Engineered on Purpose — Part 5: Custom Name Resolution, Client-Side Load Balancing, and the Triple-Slash Mystery

This is Part 5 of a series where I'm building a microservice platform from scratch. Part 1 covers the architecture, Part 4covers the BFF and service discovery setup. Full codebase on GitHub.

One of the features of Spring Eureka that gets mentioned everywhere is load balancing. Register your services, use @LoadBalanced on a RestTemplate or WebClient, and Eureka handles distributing requests across instances. It's one of the selling points. It's on the docs. It's in every tutorial.

Cool. Except I'm not using REST between my services. I'm using gRPC.

And gRPC does not care about your Eureka annotations.

This realization hit me while I was reading about Eureka's features, feeling good about the setup from Part 4, thinking I was further along than I was. The BFF was finding services through Eureka, making gRPC calls, getting responses back. But when I actually thought about it — like really thought about it — my GrpcChannelFactory was calling getNextServerFromEureka(), getting one instance, building a channel to that one address, and caching it. If I had five instances running, four of them would be collecting dust.

So that Eureka load balancing everyone talks about? It works because Spring's HTTP clients (RestTemplate, WebClient) are integrated with Eureka's service discovery at the library level. The @LoadBalanced annotation hooks into the HTTP client and intercepts requests before they go out. gRPC has its own transport layer — HTTP/2 over Netty. It doesn't go through Spring's HTTP clients. It doesn't know what @LoadBalanced means. By the time gRPC sends data, the TCP connection is already open, HTTP/2 streams are multiplexed, and Spring is out of the picture.

I needed to figure out how to get load balancing in a world where the two systems — Eureka and gRPC — don't talk to each other.

Going Down the Rabbit Hole

First thing I did was what any developer does. YouTube. Found a video where someone was doing gRPC with Eureka and had load balancing working. Got excited. Then noticed they were using a completely different gRPC dependency:

implementation("net.devh:grpc-server-spring-boot-starter:3.0.0.RELEASE")

This is the grpc-spring-boot-starter from net.devh — a more mature, battle-tested library that has Eureka integration baked in. With it, you just write:

grpc:
  client:
    catalog-service:
      address: 'discovery:///CATALOG-SERVICE'
      negotiationType: plaintext

And it works. Discovery, load balancing, the lot. You annotate your stub with @GrpcClient("catalog-service") and it's automatically load balanced through Eureka.

For a moment I considered just switching to this dependency. It would solve my problem immediately. But I was already using Spring's official spring-grpc starter, I'd built a bunch of infrastructure around it, and honestly — I wanted to understand how load balancing actually works at the gRPC level rather than having a library hide it from me.

So I kept digging.

Understanding What I Got Wrong

I started reading the gRPC docs properly. Not the quickstart. The architecture docs. And I found out I'd been misunderstanding what a channel actually does.

I thought a channel was a connection. You give it a host and port, it connects, you make calls. That's what ManagedChannelBuilder.forAddress(host, port) looks like it's doing.

But when you use forTarget("dns:///my-service") instead, something different happens. gRPC runs the target through a name resolution system. The URI scheme — dns in this case — tells gRPC which resolver to use. The resolver returns a list of addresses. Then gRPC's built-in load balancer distributes calls across those addresses.

Target → resolver → list of addresses → load balancer → pick one → call.

My code was short-circuiting all of this. By using forAddress, I was handing gRPC a pre-resolved single address. There was nothing to balance because I never let the system discover multiple addresses. I was doing gRPC's job for it — and doing it worse.

The question then became: gRPC knows how to resolve dns:// out of the box. Can I teach it to resolve eureka://?

Yes. But you have to write a custom name resolver.

Building the Bridge

gRPC's name resolution system is pluggable. You register a NameResolverProvider that claims a URI scheme, and gRPC delegates to it whenever it sees that scheme. Two classes.

The provider tells gRPC "I own the eureka scheme":

@RequiredArgsConstructor
public class EurekaNameResolverProvider extends NameResolverProvider {
    private final EurekaClient eurekaClient;
    @Override
    public NameResolver newNameResolver(URI uri, NameResolver.Args args) {
        if (!"eureka".equals(uri.getScheme())) {
            return null;
        }
        String serviceName = uri.getPath().substring(1);
        return new EurekaNameResolver(serviceName, eurekaClient);
    }
    @Override
    public String getDefaultScheme() { return "eureka"; }
    @Override
    protected boolean isAvailable() { return true; }
    @Override
    protected int priority() { return 5; }
}

The word "eureka" is arbitrary by the way. Could be banana:// for all gRPC cares. It's just a string you pick and use consistently.

Then the resolver does the actual Eureka lookup and feeds addresses into gRPC's system:

@RequiredArgsConstructor
public class EurekaNameResolver extends NameResolver {
    private final String serviceName;
    private final EurekaClient eurekaClient;
    private Listener2 listener;
    private final ScheduledExecutorService scheduler =
        Executors.newSingleThreadScheduledExecutor();
    @Override
    public void start(Listener2 listener) {
        this.listener = listener;
        resolve();
        scheduler.scheduleAtFixedRate(
            this::resolve, 10, 10, TimeUnit.SECONDS);
    }
    private void resolve() {
        List<InstanceInfo> instances =
            eurekaClient.getInstancesByVipAddress(serviceName, false);
        List<EquivalentAddressGroup> addressGroups = instances.stream()
            .map(instance -> {
                String host = instance.getHostName();
                int port = Integer.parseInt(
                    instance.getMetadata()
                        .getOrDefault("grpc-port", "9000"));
                return new EquivalentAddressGroup(
                    new InetSocketAddress(host, port));
            })
            .toList();
        if (addressGroups.isEmpty()) {
            listener.onError(Status.UNAVAILABLE
                .withDescription("No instances for " + serviceName));
            return;
        }
        listener.onResult(ResolutionResult.newBuilder()
            .setAddressesOrError(StatusOr.fromValue(addressGroups))
            .build());
    }
    @Override
    public String getServiceAuthority() { return serviceName; }
    @Override
    public void shutdown() { scheduler.shutdown(); }
}

The important detail is scheduleAtFixedRate. The resolver re-queries Eureka every 10 seconds. New instance registers? It's in the address list within 10 seconds. Instance dies? Dropped. gRPC's load balancer sees the updated list and adjusts. The system stays alive.

And now creating a channel is just:

public ManagedChannel createChannel(String serviceName) {
    return ManagedChannelBuilder
          .forTarget("eureka:///" + serviceName)
          .defaultLoadBalancingPolicy("round_robin")
          .usePlaintext()
          .build();
}

No Eureka client call. No metadata extraction. No manual instance selection. Just "talk to this service, round robin across whatever instances exist right now.”

The Triple Slash Thing

This bugged me and I know it'll bug you. eureka:///CATALOG-SERVICE — three slashes. Why?

URI format is scheme://authority/path. The authority is who's responsible for serving the resource. In http://localhost:8080/api, the authority is localhost:8080.

With eureka:///CATALOG-SERVICE, there's no authority. Two slashes after the scheme are standard, then the path starts with its own slash. scheme:// + /path = three slashes. Same reason gRPC examples use dns:///my-service.

A Note on Load Balancing Approaches

While figuring all this out, I went down a rabbit hole on how gRPC load balancing works more broadly. Turns out there are two main approaches.

Server-side load balancing is what you're probably used to from the HTTP world. A load balancer sits in front of your services and distributes requests. This can happen at the transport level (L3/L4 — the load balancer just copies TCP data between connections) or the application level (L7 — the load balancer understands HTTP/2 and can route individual streams to different backends).

Client-side load balancing is what we just built. The client knows about multiple backend instances and chooses which one to call for each request. This is gRPC's native model. The channel owns both name resolution and load balancing — the resolver provides addresses, and the balancer picks one per call.

Within client-side, there's the thick client approach (the client does all the work — discovery, health tracking, balancing algorithms) and the lookaside approach (the client asks a dedicated LB server for the best backend to use).

What we built is a thick client. The resolver handles discovery through Eureka, and gRPC's built-in round robin handles distribution. For my scale — a learning project with maybe two or three instances of each service — this is more than enough.

Reviews (0)

No reviews yet. Be the first to share your thoughts!