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