Library / SDK
graphql-java/java-dataloader avatar
graphql-java/java-dataloader

java-dataloader batches and caches the per-field fetches GraphQL resolvers keep making

A Java 11 port of Facebook DataLoader

525 stars102 forksJavaApache-2.0

At a glance

What is it?
java-dataloader is a Java 11 port of Facebook's DataLoader that coalesces concurrent key lookups into one batch call and caches the results. The catch is that the Java port requires you to dispatch the queue yourself, which is the one design decision that separates it from the original.
Who is it for?
java-dataloader suits a Java team running graphql-java that has measured an n+1 fetch problem and wants batching and caching with a small utility library under the Apache 2.0 licence. It does not suit a team on an older Java runtime, or one that wants the automatic tick-based dispatch of the original JavaScript DataLoader without changing call sites.
Can I use it commercially?
Yes. Apache-2.0 is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 1 day ago.
What is it written in?
Mainly Java, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

The n+1 problem, and what coalescing actually saves you

The case for this library is a specific GraphQL pathology. Graphql fields are resolved independently, and with a true graph of objects you may be fetching the same object many times. A naive implementation of graphql data fetchers can easily lead to the dreaded n+1 fetch problem, where one query at the top level triggers one database round trip per row in the result.

The library's stated role is to serve as an integral part of your application's data layer to provide a consistent API over various back-ends and reduce message communication overhead through batching and caching.

The mechanism is worth spelling out because it is not quite what the name suggests. You call load with a key and get back a CompletableFuture of the value, but nothing is fetched yet. The key goes into a queue. When the queue is dispatched, the queued keys are handed to your batch function as a list, and your function is expected to return a promise of a list of values. Every future created in the meantime is completed from that one response.

The Facebook authors put the effect in terms of round trips: a naive application may have issued four round-trips to a backend for the required information, but with DataLoader this application will make at most two. The rest of the quote describes what the pattern buys you structurally: it allows you to decouple unrelated parts of your application without sacrificing the performance of batch data-loading, because the loader presents an API that loads individual values while all concurrent requests are coalesced and presented to your batch loading function.

So the value is not that a single call becomes faster. It is that N independent lookups collapse into a small fixed number of calls, and that the same key is only fetched once.

The Java port's one big difference is that dispatch is your job

The README is explicit that java-dataloader is a feature-complete port of the Facebook reference implementation with one major difference, and that difference has a name: manual dispatching.

The requirement is stated twice. The key requirement is that you call dataloader.dispatch() or its variant dataloader.dispatchAndJoin() at some point in order to make the underlying calls happen to the batch loader, and in this version of data loader, this does not happen automatically.

Most of the code is ported directly from Facebook's reference implementation, with one IMPORTANT adaptation to make it work for Java 11. The adaptation is a consequence of the language. Java has no event loop tick to hang a scheduler on, and the original JavaScript implementation uses a microtask scheduling trick to flush the queue at the end of the current tick, so that all the loads issued synchronously by a resolver get batched together. Without that trick in Java, a DataLoader would dispatch after every single load call and batching would never happen. Manual dispatch is the price of that.

The reason to know this is that the call site matters. If a GraphQL framework ticks a dispatcher for you, the library works as advertised. If your framework does not, then every place that triggers a resolver has to call dispatch itself, and a missed call site means loads that never complete. That is a much more invasive integration than adding a dependency.

The README also points readers to the original documentation before going further, written by Lee Byron and Nicholas Schrock, the creators of the original data loader, and calls that a short dive into it. That is an unusual thing for a port's README to do, and it tells you the port does not diverge conceptually.

The batch function is a lambda over a list of keys

A DataLoader object requires a BatchLoader function that is responsible for loading a promise of values given a list of keys. The API is generics and fluent, and the batch function is defined with a lambda expression.

The example in the README shows the shape:

java
        BatchLoader<Long, User> userBatchLoader = new BatchLoader<Long, User>() {
            @Override
            public CompletionStage<List<User>> load(List<Long> userIds) {
                return CompletableFuture.supplyAsync(() -> {
                    return userManager.loadUsersById(userIds);
                });
            }
        };

        DataLoader<Long, User> userLoader = DataLoaderFactory.newDataLoader(userBatchLoader);

Note the contract. Your function receives the list of keys and returns a CompletionStage of a list of values, and the library takes care of matching values back to the individual futures. The ordering guarantee is spelled out in the features list: results are ordered according to insertion order of load requests. That matters if your batch function returns results in a different order than the keys it was given, because you have to align them yourself or the library will hand callers the wrong values.

The remainder of the API is the composition part. A load call returns a CompletableFuture of the requested value:

java
        CompletableFuture<User> load1 = userLoader.load(1L);

And you chain further loads off the result, which is the nested case that produces a second batch:

java
           userLoader.load(1L)
                    .thenAccept(user -> {
                        System.out.println("user = " + user);
                        userLoader.load(user.getInvitedByID())
                                .thenAccept(invitedBy -> {
                                    System.out.println("invitedBy = " + invitedBy);
                                });
                    });

            userLoader.dispatchAndJoin();

The README walks through what that last call does. The first call to dispatch causes the batched user keys, 1 and 2, to be fired at the BatchLoader function to load 2 users. Since each thenAccept callback made more calls to userLoader to get the user they have invited, another 2 user keys are given at the BatchLoader function for them. The dispatchAndJoin is used to make a dispatch call, wait for it, see if the data loader has more batched entries, and then repeat, which is the loop that turns a chain of nested loads into a sequence of small batches instead of one request per level.

Caching, priming, and the cache key function

Caching is the second half of what the library does, and the features list is specific about what you can do with it.

Load requests are cached, so data is only fetched once. You can clear individual cache keys, so data is re-fetched on the next batch queue dispatch. You can prime the cache with key and value pairs, to avoid data being fetched needlessly. And you can configure a cache key function with a lambda expression to extract the cache key from complex data loader key types.

That last item is the one that catches people. The default cache key is the key object itself, so if you key a loader by a composite or by an object without a sane equals and hashCode, the cache will not do what you expect. Supplying your own cache key function is the escape hatch.

The cache is pluggable at two levels. You can supply your own CacheMap implementations and you can supply your own ValueCache implementations. That separation lets you choose a bounded or an unbounded map, or a distributed one, without changing the loader logic.

Two more behaviours matter for correctness. Individual batch futures complete and resolve as the batch is processed, so you are not forced to wait for the whole batch to finish before using the first result. And the library deals with partial errors when a batch future fails, so one bad key in a batch of a hundred does not have to poison the entire response.

Finally, batching and caching can be disabled in configuration, which is useful in tests where you want the loader to behave like a plain function call.

Adding it to a Gradle build and running the test suite

The dependency is a single artifact on Maven Central under the group com.graphql-java, and Gradle users configure it in build.gradle:

gradle
repositories {
    mavenCentral()
}

dependencies {
    compile 'com.graphql-java:java-dataloader: 4.0.0'
}

Building from source uses the Gradle wrapper:

bash
./gradlew clean build

The repository is a Gradle project with the usual layout, including gradlew and gradlew.bat wrappers, a gradle/ directory, build.gradle, settings.gradle and gradle.properties, and a single src/ directory.

One thing to notice about the versioning. The README's dependency snippet still shows version 4.0.0, while the recent releases are v6.0.0 on 2025-11-05, v5.0.3 on 2025-09-23 and v5.0.2 on 2025-07-27. The badge in the README is also configured with a versionPrefix of 4, and the snapshot badge uses a versionPrefix of 0. So the documented snippet and the published artifacts have drifted apart, and the README example is not a reliable guide to the current version. Check Maven Central for the version you actually want.

The features list claims very high test coverage, and there is a build workflow badge wired to master.yml, so the build is exercised on every change.

Where java-dataloader is the wrong tool

The first wrong-tool case is a runtime that predates Java 11. The library is described as a pure Java 11 port, and the one IMPORTANT adaptation to the original implementation exists precisely to make it work on Java 11. If you are on Java 8, this is not the artifact for you, and the API design of CompletableFuture chaining assumes a modern JDK.

The second wrong-tool case is a resolver path where you cannot insert a dispatch call. Manual dispatching means the library only helps where someone is responsible for flushing the queue. If a large part of your fetch traffic comes from a framework path you do not control, or from background work that is not tied to a request, you will end up with futures that never complete. A framework that already ticks a DataLoader dispatcher is the good case. A framework that does not is a significant integration project.

The third wrong-tool case is a caching requirement that the semantics do not meet. The cache is populated on load, cleared explicitly per key, and primed explicitly. There is no time-based expiry described and no invalidation hook, so any write path that changes the underlying data has to clear the affected keys itself. If your entities are mutable and your reads are not eventually consistent, a stale entry is a bug you have to design around.

The alternative approach, and it is a real one, is a batching layer that sits below the ORM or the HTTP client and coalesces across the whole application rather than per loader instance. The difference in approach is scope. java-dataloader batches per DataLoader, per key type, so two loaders for two different tables produce two calls even if they could have been one. A lower-level batching proxy or a request-scoped cache intercepts every query in a transaction or request and can collapse across tables, at the cost of being a layer you have to trust with your connection handling.

There is also the option of not batching at all and fixing the query shape: a single join, a view, or a stored procedure that returns exactly the rows the response needs. That is more work per query and often faster than any batching layer, because it removes the round trips rather than merging them.

Editorial conclusion

java-dataloader suits a Java team running graphql-java that has measured an n+1 fetch problem and wants batching and caching with a small utility library under the Apache 2.0 licence. It does not suit a team on an older Java runtime, or one that wants the automatic tick-based dispatch of the original JavaScript DataLoader without changing call sites. Before adopting, check that your runtime is Java 11 or newer, wire the dispatch call into every place that triggers a resolver, and confirm that the caching semantics fit your write path, since a stale cache entry is only re-fetched when you clear it or prime it.

Frequently asked questions

What is java-dataloader used for?

It is a pure Java 11 port of Facebook's DataLoader that provides a consistent API over various back-ends and reduces message communication overhead through batching and caching. Its main use case is improving the efficiency of GraphQL query execution, where a naive data fetcher leads to the n+1 fetch problem.

How do I add java-dataloader to a Gradle project?

Add the mavenCentral() repository and a dependency on com.graphql-java:java-dataloader in build.gradle. The README shows version 4.0.0, but the recent published releases are 5.x and 6.0.0, so check Maven Central for the version you want.

Why do I have to call dispatch() in java-dataloader?

Because Java has no event loop tick to flush the queue the way the original JavaScript implementation does, the Java port requires manual dispatching. You must call dataloader.dispatch() or dataloader.dispatchAndJoin() at some point for the underlying batch loader calls to happen.

Can I use my own cache implementation with java-dataloader?

Yes. You can supply your own CacheMap implementations and your own ValueCache implementations, and you can configure a cache key function with a lambda expression to extract the cache key from complex key types.

Official sources

  1. graphql-java/java-dataloader on GitHub
  2. Issues
  3. License: Apache-2.0
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/graphql-java-java-dataloader.svg)](https://hysenlabs.com/projects/graphql-java-java-dataloader)

Was this useful?