Skip to main content

Performance

These guidelines focus on optimizing the performance of Spring Boot applications using Hibernate, with an emphasis on data economy, large-scale testing, paging, JSON data usage, and general SQL database best practices. You can find more best practices in the Database Guidelines section.

Data Economy

Database-Level Filtering

Ensure that all filtering is done at the database level rather than in memory. This approach minimizes data transfer to the application and reduces memory usage.

Example:

@Query("""
SELECT e
FROM Exercise e
WHERE e.course.id = :courseId
AND e.releaseDate >= :releaseDate
""")
List<Exercise> findExercisesByCourseAndReleaseDate(@Param("courseId") Long courseId, @Param("releaseDate") ZonedDateTime releaseDate);

Projections and DTOs

When only a subset of fields is needed, use projections or Data Transfer Objects (DTOs) instead of fetching entire entities. This reduces the amount of data loaded and improves query performance.

Example:

@Query("""
SELECT new com.example.dto.ExerciseDTO(e.id, e.title)
FROM Exercise e
WHERE e.course.id = :courseId
AND e.releaseDate >= :releaseDate
""")
List<ExerciseDTO> findExerciseDTOsByCourseAndReleaseDate(@Param("courseId") Long courseId, @Param("releaseDate") ZonedDateTime releaseDate);

Avoid Adding Rarely Used Columns to Frequently Queried Tables

For frequently queried tables (e.g., User), carefully evaluate whether you need to extend the table with additional columns that are rarely used. Since such tables are often fetched, adding more columns increases the memory and network load unnecessarily. Instead, consider introducing a new table and query the additional data only when needed.

Example:

Instead of storing the calendar subscription (ICS) token directly in the user table, an extra table was introduced:

@Entity
@Table(name = "calendar_subscription_token_store")
public class CalendarSubscriptionTokenStore extends DomainObject {

@Column(name = "token", length = 32, nullable = false, unique = true)
private String token;

@OneToOne
@JsonIgnore
@JoinColumn(name = "jhi_user_id", nullable = false, unique = true)
private User user;
}

Using JSON Data for Contained Information

When data does not need to be queried by individual fields and is always contained within another entity, storing it as JSON can greatly simplify the schema and improve performance. This is particularly suitable for nested structures that are always fetched together and never queried individually.

Example:

@Entity
public class QuizQuestionProgress extends DomainObject {

@Column(name = "user_id")
private long userId;

@Column(name = "course_id")
private long courseId;

@Column(name = "quiz_question_id")
private long quizQuestionId;

@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "progress_json", columnDefinition = "json")
private QuizQuestionProgressData progress;

Advantages:

  • Reduces unnecessary joins and table complexity.
  • Improves query and insert/update performance dramatically.
  • Simplifies maintenance and evolution of data structures.

Use JSON columns when:

  • The data is self-contained (not referenced elsewhere).
  • You don't need to filter or sort by inner JSON fields.
  • The structure changes over time, and flexibility is required.

Caching

Hibernate second-level cache is intentionally disabled in Artemis; do not reach for @Cache to "fix" a slow endpoint. Address performance through DTO projections, fetch joins, @EntityGraph, and proper indexes first. If a Spring @Cacheable is genuinely warranted (measured bottleneck, read-heavy with low write throughput), it must ship with explicit eviction. See Caching Guidelines for the policy, the rationale specific to Artemis at scale, and the canonical Hibernate-event-listener eviction pattern.

Large Scale Testing

Test with Realistic Data Loads

Given that courses can have up to 2,000 students, simulate this scale during testing to identify potential performance bottlenecks when handling large amounts of data.

Benchmarking

Perform load testing to ensure that the application can handle the expected volume of data efficiently.

Artemis has its own load generator, the Benchmarking Tool, which drives a full exam through the real REST, WebSocket and git paths rather than a synthetic query mix. That page describes how to run a comparable ladder of student counts, what a simulated student does and does not have in common with a real one, and how to read which action degraded first.

Measure the server as well as the client during a run. A result that reports only response times cannot distinguish an endpoint that got slower from a node that ran out of CPU, and the two call for opposite fixes.

Round Trips Per Request

Under exam load the cost of an endpoint is dominated by how many times it goes to the database, not by how expensive any single statement is. Spring Data's SimpleJpaRepository is annotated @Transactional(readOnly = true), so a repository call made outside an existing transaction opens and closes one of its own. What reads in the code like a single statement is two round trips, the statement and the commit or rollback. Twenty repository calls in a REST method are forty round trips before a single row reaches the client.

Three consequences follow, and they matter more than micro-optimising any one query.

Guard Hot Endpoints With a Query Count Test

Query counts do not creep up in one large step. They grow by one call at a time, each of them reasonable on its own. Pin the count of an endpoint that runs during an exam so that the next one shows up as a failing test rather than as a slow exam:

assertThatDb(() -> request.get("/api/exam/courses/" + courseId + "/exams/" + examId + "/student-exams/" + studentExamId + "/conduction",
HttpStatus.OK, StudentExam.class))
.hasBeenCalledAtMostTimes(CONDUCTION_QUERY_COUNT);

Set the asserted number to what the endpoint issues today, and write down in a comment what those queries are for. A budget with slack in it accepts the next regression silently, which is the one thing the test exists to prevent. When a change legitimately adds a query, move the number and say why in the commit.

Prefer a Modifying Query to Saving a Detached Entity

Calling save() on an entity that carries an id but is not managed makes Hibernate read the row before it writes it, so changing one column costs a select, an update and a commit. Where the columns that change are known, write the update instead:

@Modifying
@Transactional // ok because of modifying query
@Query("""
UPDATE StudentParticipation p
SET p.initializationState = :state
WHERE p.id = :participationId
""")
void updateInitializationState(@Param("participationId") long participationId, @Param("state") InitializationState state);

Two things to check before reaching for one. The entity must already exist, since an update against a null id silently writes nothing where a save() would have inserted. And the caller must not depend on the merged instance that save() returns, because a modifying query gives it nothing back and any lazy association it was about to touch is still detached.

Watch Eager To-One Associations

Because each repository call carries its own transaction, the persistence context does not survive from one call to the next. An eager @ManyToOne is therefore re-read every time a call returns the owning entity, not once per request. Two calls that each return the same participation read its exercise twice.

Fetch the chain the endpoint needs in the query that loads the root, rather than letting Hibernate discover it:

@Query("""
SELECT p
FROM StudentParticipation p
LEFT JOIN FETCH p.exercise ex
LEFT JOIN FETCH ex.exerciseGroup exerciseGroup
LEFT JOIN FETCH exerciseGroup.exam exam
WHERE p.id = :participationId
""")
Optional<StudentParticipation> findByIdWithExerciseChain(@Param("participationId") long participationId);

Joining to-one associations this way does not multiply result rows, so it is unrelated to the cartesian-product problem described under Optimal Use of Left Join Fetch. The over-fetch check in CI counts fetch joins without distinguishing the two, so a query that only walks a to-one chain may legitimately sit above the usual threshold.

Load the Acting User Once

Authorisation is the part of a request most likely to read the same rows repeatedly, because every check is written to be safe on its own. Load the user once, with what the checks need:

User user = userRepository.getUserWithCourseRolesAndAuthorities();

With the course roles present, a role check resolves in memory instead of issuing an EXISTS query, and with the authorities initialised nothing downstream re-reads the user to inspect them. Passing that user into the check, rather than letting the check look it up, is what keeps the saving.

Validate Against a Projection, Not an Entity Graph

A check that needs three fields should read three fields. Loading an entity to compare two dates pulls in its eager associations as well, and each of those is a join or a second statement. A record projection with a database-side count answers the same question in one statement:

@Query("""
SELECT new de.tum.cit.aet.artemis.exam.dto.StudentExamSubmissionGateDTO(se.id, se.submitted, se.startedDate, se.workingTime,
(SELECT COUNT(ex) FROM se.exercises ex WHERE ex.id = :exerciseId))
FROM StudentExam se
WHERE se.exam.id = :examId AND se.user.id = :userId AND se.testRun = FALSE
""")
Optional<StudentExamSubmissionGateDTO> findSubmissionGate(@Param("examId") long examId, @Param("userId") long userId, @Param("exerciseId") long exerciseId);

Paging

Implement Paging for Large Results

For queries that return large datasets, implement pagination to avoid loading too much data into memory at once.

Example:

Page<Exercise> findByCourseId(Long courseId, Pageable pageable);

Prefer Slice over Page When Counts Are Not Needed

When you do not need numbered pages or total element counts, prefer using Slice instead of Page. Page always triggers an additional count query, which can degrade performance on large datasets.

Example:

@Query("""
SELECT b.id
FROM BuildJob b
WHERE b.buildStatus NOT IN (
de.tum.cit.aet.artemis.programming.domain.build.BuildStatus.QUEUED,
de.tum.cit.aet.artemis.programming.domain.build.BuildStatus.BUILDING
)
""")
Slice<Long> findFinishedIds(Pageable pageable);

Caution with Collection Fetching and Pagination

Avoid combining LEFT JOIN FETCH with pagination, as this can cause performance issues or even fail due to the Cartesian Product problem. Fetch related collections separately if needed.

You can find out more at https://vladmihalcea.com/hibernate-query-fail-on-pagination-over-collection-fetch

Avoiding the N+1 Issue

Eager Fetching and Join Fetch

The N+1 query issue occurs when lazy-loaded collections cause multiple queries to be executed — one for the parent entity and additional queries for each related entity. To avoid this issue, use JOIN FETCH or @EntityGraph for performance-critical collections.

Example:

@Query("""
SELECT e
FROM Exercise e
JOIN FETCH e.submissions
WHERE e.course.id = :courseId
""")
List<Exercise> findExercisesWithSubmissions(@Param("courseId") Long courseId);

Be cautious: fetching too many relationships at once can lead to large result sets and degraded performance.

Optimal Use of Left Join Fetch

Balance Between Queries

While reducing the number of queries by using LEFT JOIN FETCH is often beneficial, overusing this strategy can lead to performance issues — especially when fetching multiple OneToMany relationships. As a rule of thumb, avoid fetching more than three collections in a single query.

A script (supporting_scripts/find_slow_queries.py) automatically checks for excessive use of JOIN FETCH or @EntityGraph and runs as part of the GitHub Action Query Quality Check.

Example:

@Query("""
SELECT c
FROM Course c
LEFT JOIN FETCH c.exercises e
LEFT JOIN FETCH e.participations
WHERE c.id = :courseId
""")
Course findCourseWithExercisesAndParticipations(@Param("courseId") Long courseId);

Selective Fetching and FetchType Rules

Use lazy loading by default and override it selectively in queries when necessary.

Example:

@Entity
public class Exercise {

@OneToMany(fetch = FetchType.LAZY, mappedBy = "exercise")
private List<Participation> participations;
}

General SQL Database Best Practices

Indexing

Indexes are critical for query performance, especially on columns frequently used in WHERE clauses, JOIN conditions, or sorting. While indexes speed up reads, they also increase storage and can slow down writes. Evaluate these tradeoffs carefully.

Example:

CREATE INDEX idx_exercise_release_date ON exercise(release_date);

Normalization vs. Denormalization

Normalization reduces redundancy, but too much can lead to expensive joins. In performance-critical read scenarios, consider moderate denormalization — or JSON columns — to minimize joins.

Use of Foreign Keys

Maintain foreign key constraints to ensure data integrity. Proper indexing can help mitigate performance costs on high-load operations.

Example:

ALTER TABLE submission ADD CONSTRAINT fk_exercise FOREIGN KEY (exercise_id) REFERENCES exercise(id);

Query Optimization

Use tools like EXPLAIN to review execution plans and optimize slow queries.

Example:

EXPLAIN SELECT * FROM exercise WHERE course_id = 1 AND release_date > '2024-01-01';

Sorting and Counting at the Database Level

Always perform sorting, filtering, and counting at the database level whenever possible.

Avoid Transactions

A transaction boundary may only be declared inside a repository, and in practice only on a @Modifying query that needs one. @Transactional, TransactionTemplate and PlatformTransactionManager are not allowed in services or controllers.

Two architecture tests enforce this over the whole of production code, with no exception list. ArchitectureTest.testTransactionBoundariesOnlyInRepositories requires that a @Transactional method be declared in a repository interface and that no class outside one carries the annotation. ArchitectureTest.testNoProgrammaticTransactionManagement forbids production code from touching anything in org.springframework.transaction other than the annotation package, which is what closes the TransactionTemplate and PlatformTransactionManager spelling of the same boundary. Test code is exempt from the second rule: a test may legitimately open a transaction to seed data, or to hold a row lock while asserting that the code under test waits for it.

The reason is not that transactions are slow in themselves. It is that the cost of one scales with how long it stays open and how much it touches, and a boundary declared high up in a service is open for the whole call.

A wide transaction holds its locks for its entire span. Every row a statement writes stays locked until the commit, so the transaction blocks anyone who needs those rows for as long as it runs — not for as long as the write took. A service method that spans a dozen repository calls, an HTTP request to another system and a file-system write keeps its first row locked across all of it. During an exam that is the difference between a slow endpoint and a stalled cohort.

Wide transactions deadlock. Two of them that touch the same rows in a different order will eventually wait on each other, and the database resolves it by killing one. This failure is load-dependent: it does not appear in a test, it appears in the exam. The wider the boundary, the more rows it covers, and the more orderings exist to collide.

They are usually added for a failure that does not happen. The argument for a broad boundary is almost always a rollback scenario — a partial write that must not survive. Ask how the run reaches that state, and how often. Most of the time the answer is a case that has never occurred, while the locking and deadlock cost is paid on every single request. Where a partial write really is unacceptable, the narrow fix is to order the writes so that the incomplete state is harmless, or to make the operation idempotent and retry it, rather than to wrap the whole flow.

A self-invoked @Transactional method silently does nothing. Spring applies the annotation through a proxy, so calling the method from another method of the same class bypasses it:

@Transactional
public void claimPendingJobs() { ... } // FOR UPDATE SKIP LOCKED needs the transaction

public void onCallback() {
claimPendingJobs(); // proxy bypassed: runs without a transaction, no warning
}

Nothing fails and nothing is logged. A SELECT ... FOR UPDATE SKIP LOCKED written to claim work then runs in autocommit, releases its lock immediately, and two nodes take the same job. The same trap applies to @Async — the method runs inline on the caller's thread — and to @Cacheable.

Keeping boundaries inside repositories avoids all of this by construction: a repository method is one statement, its transaction is as short as that statement, and it is always reached through the proxy.

What to write instead

Almost every boundary that looked necessary in a service turns out to be one of two patterns.

Making a check and a write atomic: put the check in the WHERE clause. A boundary is often there so that nothing can change between reading a row and writing it. A single statement cannot be interleaved, so move the condition into the query and let the row count tell you whether you won:

@Transactional // ok because of modifying query
@Modifying
@Query("""
UPDATE AnswerPost answerPost
SET answerPost.verified = TRUE, answerPost.verifiedBy = :verifier
WHERE answerPost.id = :answerPostId AND answerPost.verified = FALSE
""")
int updateVerificationIfUnverified(@Param("answerPostId") long answerPostId, @Param("verifier") User verifier);

Two tutors pressing approve at the same moment both pass a preceding isVerified() check; only one of them updates a row, and the other is told the answer is already verified. AnswerPostRepository.verifyIfUnverified and LectureUnitProcessingStateRepository.claimIdleForDispatch are the worked examples. This also replaces SELECT ... FOR UPDATE SKIP LOCKED for claiming queued work.

Undoing work when a later step fails: compensate explicitly. Record what an operation created and put it back in a catch block. SlideSplitterService.SlideOperation is the pattern: it collects the files written and the rows saved, and restores both if any step throws. This is also what replaces TransactionSynchronizationManager, which cannot work at all without a boundary — see ArchitectureTest.testNoTransactionSynchronization.

Server Startup Performance

Why It Matters

Fast startup improves developer feedback cycles and enables rolling deployments without user-facing disruptions or degraded performance.

What Has Been Done

All Spring beans are now marked as @Lazy by default to prevent instantiation at startup. A Deferred Eager Bean Instantiation mechanism initializes remaining beans asynchronously after startup. If initialization fails, the application stops to avoid partial functionality.

Keeping the Number of Beans Minimal

A GitHub Action — Check Bean Instantiations on Startup and with Deferred Eager Initialization — validates:

  • The number of beans instantiated at startup
  • The length of dependency chains

It fails when thresholds are exceeded and provides detailed diagnostics for performance tuning.

By following these best practices — especially regarding JSON usage, strict control of fetch types, and efficient query design — you can build Spring Boot applications with Hibernate that are optimized for performance both at runtime and during startup.

Search documentation