Earlier this week, I asked other Spring Boot developers to test my side project, N1netails Dashboard—a self-hosted, developer-friendly alerting and monitoring platform built with Spring Boot (backend) and Angular (frontend).
The goal of N1netails is to provide an open-source alternative to heavy SaaS solutions like PagerDuty, making it easy for developers to send and manage alerts from their applications. It’s designed to be lightweight, customizable, and perfect for small teams or embedded use cases.
During testing, we discovered a critical issue: my small 1GB DigitalOcean droplet kept crashing with an OutOfMemoryError. The culprit? Some API endpoints were returning all rows from certain database tables at once.
I knew this was bound to happen eventually, but I didn’t expect it so soon. This reminded me why early testing—especially with QA engineers or security-minded red teamers—is crucial for side projects. Catching performance bottlenecks early saves you from production headaches later.
The fix was simple but powerful: implementing pagination in both my Spring Boot backend and Angular frontend. Here’s how I did it.
What is pagination?
Pagination splits large datasets into smaller, manageable pages instead of loading everything at once. This keeps your app responsive and avoids memory crashes.
Think of Google Search—when you search for something, it doesn’t show millions of results at once. Instead, it loads 10–20 results per page, improving performance and user experience.
In my case, returning thousands of database rows in a single API response was eating up memory. Pagination limited each request to 50 rows at a time, fixing the OutOfMemoryError instantly.
Implementing pagination in my Spring Boot application
I implemented pagination in four steps.
1. Create a PageRequest
POJO
This object lets the frontend specify page size, sorting, and optional search terms.
import lombok.Getter;
import lombok.Setter;
import org.springframework.data.domain.Sort;
@Getter
@Setter
public class PageRequest {
private int pageNumber = 0;
private int pageSize = 10;
private Sort.Direction sortDirection = Sort.Direction.ASC;
private String sortBy = "id";
private String searchTerm;
}
✔ What each field does
-
pageNumber
→ Which page to load (starts at 0). -
pageSize
→ How many rows per page. -
sortDirection
→ ASC or DESC. -
sortBy
→ Which column to sort by. -
searchTerm
→ Optional filter for the results.
2. Update the Repository
For my tail_type
table, I updated the repository to use Spring Data JPA’s Page
and Pageable
.
@Repository
public interface TailTypeRepository
extends PagingAndSortingRepository<TailTypeEntity, Long>,
JpaRepository<TailTypeEntity, Long> {
Optional<TailTypeEntity> findTailTypeByName(String name);
Page<TailTypeEntity> findByNameContainingIgnoreCase(String searchTerm, Pageable pageable);
}
Behind the scenes, Spring converts this method into SQL like:
SELECT t.*
FROM tail_type t
WHERE LOWER(t.name) LIKE LOWER(CONCAT('%', :searchTerm, '%'))
ORDER BY t.id ASC
LIMIT :pageSize OFFSET :offset;
3. Update the Service Layer
The service takes the PageRequest
, builds a Pageable
, and maps TailTypeEntity
→ TailTypeResponse
.
@Override
public Page<TailTypeResponse> getTailTypes(PageRequest request) {
Sort sort = Sort.by(request.getSortDirection(), request.getSortBy());
Pageable pageable = org.springframework.data.domain.PageRequest.of(
request.getPageNumber(), request.getPageSize(), sort
);
Page<TailTypeEntity> tailTypeEntities =
(request.getSearchTerm() != null && !request.getSearchTerm().isEmpty())
? tailTypeRepository.findByNameContainingIgnoreCase(request.getSearchTerm(), pageable)
: tailTypeRepository.findAll(pageable);
List<TailTypeResponse> tailTypeResponseList = tailTypeEntities
.stream()
.map(this::generateTailTypeResponse)
.toList();
return new PageImpl<>(tailTypeResponseList, pageable, tailTypeEntities.getTotalElements());
}
4. Expose Pagination in the Controller
Finally, the controller accepts a PageRequest
and returns a paginated response.
@Operation(summary = "Get all tail types", responses = {
@ApiResponse(responseCode = "200", description = "Paginated tail types",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = TailTypeResponse.class))))
})
@GetMapping
public ResponseEntity<Page<TailTypeResponse>> getTailTypes(@ParameterObject PageRequest request) {
return ResponseEntity.ok(tailTypeService.getTailTypes(request));
}
Angular Implementation
Once the backend was ready, I added pagination support to the Angular frontend.
1. Define Interfaces
These match the backend request and response.
export interface PageRequest {
pageNumber: number;
pageSize: number;
sortDirection: string;
sortBy: string;
searchTerm?: string;
}
export interface PageResponse<T> {
content: T[];
totalPages: number;
totalElements: number;
size: number;
number: number; // current page number
}
2. Create a Pagination Utility Service
A helper service to generate default pagination params.
@Injectable({ providedIn: 'root' })
export class PageUtilService {
getPageRequestParams(pageRequest: PageRequest): HttpParams {
let params = new HttpParams()
.set('pageNumber', pageRequest.pageNumber)
.set('pageSize', pageRequest.pageSize)
.set('sortDirection', pageRequest.sortDirection)
.set('sortBy', pageRequest.sortBy);
if (pageRequest.searchTerm) {
params = params.set('searchTerm', pageRequest.searchTerm);
}
return params;
}
setDefaultPageRequest(): PageRequest {
return { pageNumber: 0, pageSize: 50, sortDirection: "ASC", sortBy: "id" };
}
setDefaultPageRequestWithSearch(term: string): PageRequest {
return { ...this.setDefaultPageRequest(), searchTerm: term };
}
}
3. Call the API in a Service
getTailTypes(pageRequest: PageRequest): Observable<PageResponse<TailTypeResponse>> {
let params = this.pageUtilService.getPageRequestParams(pageRequest);
return this.http.get<PageResponse<TailTypeResponse>>(this.host, { params });
}
4. Display Paginated Data
Finally, I used the service in my settings component:
private listTailStatus() {
const pageRequest: PageRequest = this.pageUtilService.setDefaultPageRequest();
this.tailStatusService.getTailStatusList(pageRequest)
.subscribe((response: PageResponse<TailStatusResponse>) => {
this.tailStatuses = response.content;
});
}
And rendered it in the template:
<div class="alert-list">
<h4>Tail Types</h4>
<div class="alert-form">
<input nz-input [(ngModel)]="searchTailType" (ngModelChange)="searchType()" name="searchTailType"
placeholder="Search type" class="alert-input" />
<button nz-button nzType="default" (click)="searchType()" class="alert-btn"><nz-icon nzType="search"
nzTheme="outline" /></button>
</div>
<ul>
<li *ngFor="let type of tailTypes">
{{ type.name }}
<button *ngIf="type.deletable" nz-button nzType="link" nzDanger
(click)="removeAlertType(type.name)">Remove</button>
</li>
</ul>
<div class="alert-form">
<input nz-input [(ngModel)]="newTailType" name="newTailType" placeholder="Add new type" class="alert-input" />
<button nz-button nzType="default" (click)="addAlertType()" class="alert-btn">+</button>
</div>
</div>
Final Thoughts
Before pagination, my /ninetails/tail-type
endpoint returned thousands of rows, causing my 1GB DigitalOcean droplet to crash. After implementing pagination, each request now loads only 50 rows, fixing the issue and making the app much faster.
If you want to test it yourself, check out N1netails Dashboard or the N1netails GitHub.
Questions? Contributions? Join me on Discord.