Fixing OutOfMemoryError in Spring Boot: Implementing Pagination (With Angular Example)

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.

Google Pagination

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.

PageRequest

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.

TailTypeRepository

@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 TailTypeEntityTailTypeResponse.

TailTypeServiceImpl

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

TailTypeController

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

Page Interface

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.

PageUtilService

@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

TailTypeService

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:

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

Setting component HTML

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

Leave a Reply