JsonApi4j’s performance is dominated by relationship resolution — the number of downstream calls made when building responses, especially for compound documents. All optimizations below target reducing or parallelizing these calls.

Implement Bulk Resource Reads

When compound documents are enabled, the resolver fetches included resources by their IDs. If your ReadMultipleResourcesOperation supports the filter[id] parameter (see Filtering and Sorting), the resolver batches these into a single request:

GET /countries?filter[id]=US,DE,FR

If no bulk operation is available, the framework falls back to sequential read-by-id calls — one HTTP request per resource. For 20 included resources, that is 20 sequential calls instead of 1.

public class CountryOperations implements ResourceOperations<CountryDto> {

    @Override
    public PaginationAwareResponse<CountryDto> readPage(JsonApiRequest request) {
        List<String> filterIds = request.getFilterIds(); // e.g. ["US", "DE", "FR"]
        if (filterIds != null && !filterIds.isEmpty()) {
            return PaginationAwareResponse.fromItemsNotPageable(
                countryService.findByIds(filterIds)
            );
        }
        return PaginationAwareResponse.cursorAware(
            countryService.findAll(request.getPaginationRequest()),
            request.getCursor()
        );
    }
}

This is the single most impactful optimization for compound document performance.

Use Batch Relationship Operations

When fetching multiple primary resources (e.g., GET /users), the framework resolves each user’s relationships. By default, this means N separate calls — one per user, per relationship.

BatchReadToManyRelationshipOperation and BatchReadToOneRelationshipOperation replace N calls with a single batch call. Implement the readBatches() method:

A relationship only ever emits a resource identifier ({type, id}), so a batch op can return lightweight refs (CountryRef) instead of full resources — the recommended default for performance, since the related resources are fetched separately and in bulk (see Implement Bulk Resource Reads). Either way, the batch turns N per-user linkage lookups into one.

public class UserCitizenshipsOperations implements
        ToManyRelationshipOperations<UserDbEntity, CountryRef>,
        BatchReadToManyRelationshipOperation<UserDbEntity, CountryRef> {

    // Standard single-user read (used for GET /users/{id}/relationships/citizenships)
    @Override
    public PaginationAwareResponse<CountryRef> readMany(JsonApiRequest request) {
        return PaginationAwareResponse.inMemoryCursorAware(
            userDb.getUserCitizenships(request.getResourceId()).stream()
                .map(CountryRef::new).toList()
        );
    }

    // Batch read for all users at once (used for GET /users when resolving relationships)
    @Override
    public Map<UserDbEntity, PaginationAwareResponse<CountryRef>> readBatches(
            JsonApiRequest request,
            List<UserDbEntity> users) {
        // One call loads every user's citizenship ids — turning N per-user lookups into 1.
        Set<String> userIds = users.stream().map(UserDbEntity::getId).collect(Collectors.toSet());
        Map<String, List<String>> citizenshipsPerUser = userDb.getUsersCitizenships(userIds);

        Map<String, UserDbEntity> usersById = users.stream()
            .collect(Collectors.toMap(UserDbEntity::getId, u -> u));
        return citizenshipsPerUser.entrySet().stream().collect(Collectors.toMap(
            e -> usersById.get(e.getKey()),
            e -> PaginationAwareResponse.inMemoryCursorAware(
                e.getValue().stream().map(CountryRef::new).toList()
            )
        ));
    }
}

Impact: Resolving citizenships for 50 users goes from 50 per-user linkage lookups to 1. And because this relationship returns refs, it avoids fetching a full country just to emit {type, id} — the countries are materialized only when ?include=citizenships is requested, in bulk via filter[id].

The same pattern applies to to-one relationships via BatchReadToOneRelationshipOperation:

public class UserPlaceOfBirthOperations implements
        ToOneRelationshipOperations<UserDbEntity, CountryRef>,
        BatchReadToOneRelationshipOperation<UserDbEntity, CountryRef> {

    @Override
    public Map<UserDbEntity, CountryRef> readBatches(JsonApiRequest request,
                                                     List<UserDbEntity> users) {
        // One call loads every user's place-of-birth country id; wrap each in a lightweight ref.
        Map<String, UserDbEntity> usersById = users.stream()
            .collect(Collectors.toMap(UserDbEntity::getId, u -> u));
        Map<String, String> placeOfBirthPerUser = userDb.getUsersPlaceOfBirth(usersById.keySet());

        return placeOfBirthPerUser.entrySet().stream().collect(Collectors.toMap(
            e -> usersById.get(e.getKey()),
            e -> new CountryRef(e.getValue())
        ));
    }
}

Once you implement a batch operation, the framework uses it for all scenarios — there is no need to also implement the single-resource operation separately.

Leverage In-House Relationship Resolution

Sometimes your parent resource DTO already contains the relationship data. For example, UserDbEntity might hold a placeOfBirthCountryCode field. Instead of making a separate downstream call to resolve the relationship, you can extract it directly.

Override readManyForResource() or readOneForResource() to resolve relationships from the parent DTO in memory:

public class UserPlaceOfBirthOperations implements
        ToOneRelationshipOperations<UserDbEntity, CountryRef> {

    // Called when resolving relationships during parent resource reads
    @Override
    public CountryRef readOneForResource(JsonApiRequest request, UserDbEntity user) {
        // Resolve directly from the parent DTO — no downstream call, just the id the linkage needs
        String countryCode = user.getPlaceOfBirthCountryCode();
        return countryCode != null ? new CountryRef(countryCode) : null;
    }

    // Called for direct relationship endpoint: GET /users/{id}/relationships/placeOfBirth
    @Override
    public CountryRef readOne(JsonApiRequest request) {
        return new CountryRef(userDb.getUserPlaceOfBirth(request.getResourceId()));
    }
}

This eliminates downstream calls entirely for relationships where the linkage id is already available in the parent model.

Tune the Executor

JsonApi4j uses an Executor for parallel relationship resolution. When a resource has multiple relationships, they are resolved concurrently. The default is synchronous execution (Runnable::run).

Provide a custom Executor bean to enable parallelism:

The executor is yours, not the framework’s, so its Java requirements are yours too. JsonApi4j itself targets Java 17 and uses no Java 21 API, but nothing stops you handing it a virtual-thread executor when your application runs on 21 or newer — which for I/O-bound relationship resolution is usually the right choice.

@Configuration
public class JsonApi4jConfig {

    @Bean
    public ExecutorService jsonApi4jExecutorService() {
        // Virtual threads (Java 21+) — lightweight, ideal for I/O-bound work
        return Executors.newVirtualThreadPerTaskExecutor();
    }
}
public class JsonApi4jConfig {

    @Produces
    @Singleton
    public ExecutorService jsonApi4jExecutorService() {
        return Executors.newVirtualThreadPerTaskExecutor();
    }
}

Common strategies:

Executor Best for With compound documents
Runnable::run (default) Simple APIs with few relationships safe
Executors.newCachedThreadPool() Dynamic scaling for variable workloads safe — grows when all threads are busy
Executors.newVirtualThreadPerTaskExecutor() I/O-bound relationship resolution (Java 21+) safe — a blocked virtual thread costs nothing
ForkJoinPool (commonPool() or new ForkJoinPool(n)) CPU-bound work safe — a join on a pool thread adds a temporary compensating thread
ThreadPoolExecutor with a SynchronousQueue and CallerRunsPolicy Bounded concurrency safe — when saturated, the submitting thread runs the task itself
Executors.newFixedThreadPool(N), or any bounded pool with a task queue Bounded concurrency without compound documents can deadlock

With compound documents, the executor must tolerate nested waits. Included resources are fetched on the executor, and an in-process read of a same-app type resolves its relationships on the same executor — then waits for them. A pool thread fetching an include ends up waiting for tasks queued on its own pool. A pool that can grow, or that runs a task on the submitting thread when full, handles that. A fixed pool with a queue doesn’t: once every thread waits on queued work, nothing runs it, and the request hangs — in-process reads have no timeout to break the wait.

If you need a hard cap on threads, keep it and drop the queue:

new ThreadPoolExecutor(
        0, 64,                                   // at most 64 threads
        60, TimeUnit.SECONDS,
        new SynchronousQueue<>(),                // no queue: a task needs a free thread...
        new ThreadPoolExecutor.CallerRunsPolicy() // ...or runs on the thread that submitted it
);

Parallelism helps most when a resource has multiple relationships that each trigger downstream calls. If relationships are resolved in-house (see above), the overhead of thread scheduling may outweigh the benefit.

Fetch Same-App Includes In-Process

Resource types the app serves itself are fetched for included either in-process — the read runs through the framework directly — or over HTTP, with the app calling its own address (see How includes are fetched). Prefer IN_PROCESS, the default, wherever it applies. Over HTTP every include fetch pays a full round trip: a socket, every servlet filter, the JSON written and parsed again — and it holds a second worker thread while the original request waits, which under load can exhaust a bounded thread pool.

jsonapi4j:
  cd:
    mapping:
      default:
        url: http://127.0.0.1:8080/jsonapi
      countries:
        transport: HTTP     # only where servlet filters must see the include requests

In a rough local benchmark with in-memory data, a 3-level include was ~1.8× faster per request in-process, at ~1.6× the throughput; requests without includes performed the same. Switch a type to HTTP only when something that runs as a servlet filter — URL-based security rules, logging, metrics — has to see its include requests.

Limit Compound Document Depth

The ?include parameter supports multi-level traversal (e.g., ?include=orders.lineItems.product). Each level multiplies the number of downstream requests. Set limits to prevent unbounded resolution:

jsonapi4j:
  cd:
    maxHops: 2              # Maximum relationship nesting depth
    maxIncludedResources: 50 # Maximum total included resources per response
Property Default Effect
maxHops 2 The deepest ?include path supported. With 2, a.b works and a.b.c is answered with 400 Bad Request — or, with unsupportedIncludes: IGNORE, resolved to a.b and reported in meta.
maxIncludedResources 100 Stops fetching further hops once included holds this many resources, reporting the unresolved paths in meta. Prevents a single request from triggering thousands of downstream calls.

For APIs with deep relationship graphs, start with maxHops: 1 and increase only if clients need deeper traversal.

Enable Compound Document Caching

The compound document resolver includes an in-memory LRU cache that stores individual resolved resources. When the same resource is included across multiple requests, it is served from cache instead of fetching again.

jsonapi4j:
  cd:
    cache:
      enabled: true
      maxSize: 1000    # Maximum number of cached resource entries

Only resources fetched over HTTP are cached — same-app types fetched in-process are cheap to read and always fresh.

Cache keys include the resource type, ID, downstream includes, and sparse fieldsets — so GET /users/1?include=orders and GET /users/1?include=orders&fields[orders]=total are cached separately.

The cache respects Cache-Control headers from downstream responses. Resources with no-store or no-cache directives are not cached. TTL is derived from max-age or s-maxage.

Custom Cache Implementation

For distributed deployments, replace the built-in in-memory cache with a custom implementation (e.g., Redis). Extend AbstractCompoundDocsResourceCache, which enforces cacheability checks before storing:

public class RedisCompoundDocsCache extends AbstractCompoundDocsResourceCache {

    private final RedisTemplate<String, String> redis;

    @Override
    public Optional<CacheResult> get(CacheKey key) {
        String json = redis.opsForValue().get(key.toString());
        if (json == null) return Optional.empty();
        Long ttl = redis.getExpire(key.toString(), TimeUnit.SECONDS);
        return Optional.of(new CacheResult(json, CacheControlDirectives.ofMaxAge(ttl)));
    }

    @Override
    protected void doPut(CacheKey key, String resourceJson, CacheControlDirectives directives) {
        long ttl = directives.effectiveMaxAge();
        redis.opsForValue().set(key.toString(), resourceJson, ttl, TimeUnit.SECONDS);
    }

    // Override getAll/putAll for batch efficiency with Redis MGET/MSET
    @Override
    public Map<CacheKey, CacheResult> getAll(Collection<CacheKey> keys) {
        // Use Redis MGET for batch retrieval
    }
}

Register the custom cache as a bean, and it replaces the built-in implementation automatically.

Summary

Optimization Impact When to use
Bulk reads (filter[id]) High Always — required for efficient compound docs
Batch relationship operations High APIs serving list endpoints with relationships
In-house relationship resolution Medium When parent DTOs contain relationship data
In-process includes (transport: IN_PROCESS) High Always for same-app types, unless servlet filters must see includes
Executor tuning Medium Resources with multiple relationships and I/O-bound resolution
Compound doc limits Safety Always — prevents runaway resolution
Compound doc caching Medium Repeated requests for the same included resources