Performance Tuning
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:
@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 |