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:

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

    // Standard single-user read (used for GET /users/{id}/relationships/citizenships)
    @Override
    public PaginationAwareResponse<DownstreamCountry> readMany(JsonApiRequest request) {
        List<String> citizenshipIds = userDb.getUserCitizenships(request.getResourceId());
        return PaginationAwareResponse.inMemoryCursorAware(
            countriesClient.fetchByIds(citizenshipIds)
        );
    }

    // Batch read for all users at once (used for GET /users when resolving relationships)
    @Override
    public Map<UserDbEntity, PaginationAwareResponse<DownstreamCountry>> readBatches(
            JsonApiRequest request,
            List<UserDbEntity> users) {
        // 1. Collect all citizenship IDs across all users
        Set<String> userIds = users.stream().map(UserDbEntity::getId).collect(Collectors.toSet());
        Map<String, List<String>> citizenshipsPerUser = userDb.getUsersCitizenships(userIds);

        // 2. Fetch all countries in one call
        List<String> allCountryIds = citizenshipsPerUser.values().stream()
            .flatMap(Collection::stream).distinct().toList();
        Map<String, DownstreamCountry> countries = countriesClient.fetchByIds(allCountryIds)
            .stream().collect(Collectors.toMap(DownstreamCountry::getCca2, c -> c));

        // 3. Map back to each user
        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(countries::get).filter(Objects::nonNull).toList()
            )
        ));
    }
}

Impact: Fetching 50 users with citizenships goes from 50 downstream calls to 2 (one to load all citizenship IDs, one to fetch all countries).

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

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

    @Override
    public Map<UserDbEntity, DownstreamCountry> readBatches(JsonApiRequest request,
                                                            List<UserDbEntity> users) {
        // Collect distinct country IDs, fetch once, map back
        Map<String, String> placeOfBirthPerUser = userDb.getUsersPlaceOfBirth(
            users.stream().map(UserDbEntity::getId).collect(Collectors.toSet())
        );
        Map<String, DownstreamCountry> countries = countriesClient.fetchByIds(
            placeOfBirthPerUser.values().stream().distinct().toList()
        ).stream().collect(Collectors.toMap(DownstreamCountry::getCca2, c -> c));

        return placeOfBirthPerUser.entrySet().stream().collect(Collectors.toMap(
            e -> usersById.get(e.getKey()),
            e -> countries.get(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, DownstreamCountry> {

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

    // Called for direct relationship endpoint: GET /users/{id}/relationships/placeOfBirth
    @Override
    public DownstreamCountry readOne(JsonApiRequest request) {
        String countryCode = userDb.getUserPlaceOfBirth(request.getResourceId());
        return countriesClient.fetchById(countryCode);
    }
}

This eliminates downstream calls entirely for relationships where the linkage data 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:

@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
Runnable::run (default) Simple APIs with few relationships
Executors.newFixedThreadPool(N) Predictable concurrency with bounded threads
Executors.newCachedThreadPool() Dynamic scaling for variable workloads
Executors.newVirtualThreadPerTaskExecutor() I/O-bound relationship resolution (Java 21+)

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.

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 3 Limits ?include=a.b.c depth. A value of 2 means a.b works but a.b.c stops at b.
maxIncludedResources 100 Caps the total number of resolved included resources. 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

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
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