Last semester I worked on the Industry–Student Collaboration Platform (ISCP) — a workflow-driven app where students and employers collaborate on projects. The stack: React frontend, Spring Boot REST API, PostgreSQL, AWS. Under realistic load, several endpoints were taking 800ms–1.2s. We got that down to ~500–700ms — about a 30% improvement — without adding caching. Here’s how.
Finding the slow endpoints
The first step was knowing which endpoints were slow. I used two approaches:
- Chrome DevTools Network tab — Load the app, trigger the main workflows (listing projects, loading student submissions, fetching activity charts), and sort by "Time". The
/api/projects,/api/submissions, and/api/activity/summaryendpoints consistently showed 800ms+. - Repeat request timing — I ran the same requests 5–10 times and recorded p95. That ruled out cold starts and one-off spikes.
Example of what I was seeing:
GET /api/projects?page=0&size=20 ~950ms
GET /api/submissions?projectId=1 ~1100ms
GET /api/activity/summary?projectId=1 ~820ms
Root cause: N+1 and missing indexes
Inspecting the Spring Boot logs and @Query definitions, I found:
- N+1 queries — Fetching a project list triggered separate queries for each project’s submissions and activity counts.
- Full table scans — Queries on
project_id,created_at, andstatushad no supporting indexes. - Over-fetching — DTOs returned full entity graphs (nested objects, unnecessary fields) instead of slim projections.
Fix 1: Add PostgreSQL indexes
I added composite indexes for the most common filter and sort combinations:
-- Projects list: filter by status, sort by created_at
CREATE INDEX idx_projects_status_created
ON projects(status, created_at DESC);
-- Submissions: filter by project, paginate
CREATE INDEX idx_submissions_project_created
ON submissions(project_id, created_at DESC);
-- Activity summary: aggregate by project
CREATE INDEX idx_activity_project_type
ON activity_logs(project_id, activity_type);
These alone cut the projects list endpoint from ~950ms to ~650ms.
Fix 2: Rewrite joins with JPQL projections
Instead of loading full entities and letting Hibernate resolve relationships, I switched to explicit JPQL with constructor expressions:
@Query("""
SELECT new com.iscp.dto.ProjectSummaryDto(
p.id, p.title, p.status, p.createdAt,
(SELECT COUNT(s) FROM Submission s WHERE s.project = p),
(SELECT COUNT(a) FROM ActivityLog a WHERE a.project = p)
)
FROM Project p
WHERE (:status IS NULL OR p.status = :status)
ORDER BY p.createdAt DESC
""")
Page<ProjectSummaryDto> findProjectSummaries(
@Param("status") String status,
Pageable pageable
);
This removed N+1: one query returns projects with counts, no lazy loading.
Fix 3: Pagination and server-side filtering
The original endpoint returned all projects and filtered in memory. I added Spring Data Pageable and pushed filters into the query:
@GetMapping("/api/projects")
public ResponseEntity<Page<ProjectSummaryDto>> getProjects(
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size
) {
Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
return ResponseEntity.ok(projectService.findSummaries(status, pageable));
}
The frontend already had pagination UI; we just wired it to these params.
Fix 4: DTO payload trimming
Some DTOs were returning 15+ fields when the UI needed 5. I created slim DTOs for list views:
public record ProjectSummaryDto(
Long id,
String title,
String status,
Instant createdAt,
long submissionCount,
long activityCount
) {}
And for the activity summary, I stopped returning full User objects — only userId and userName:
public record ActivitySummaryDto(
Long id,
String activityType,
Long userId,
String userName,
Instant createdAt
) {}
Smaller JSON payloads meant faster serialization and less network transfer.
Before and after
| Endpoint | Before | After |
|----------|--------|-------|
| GET /api/projects | ~950ms | ~620ms |
| GET /api/submissions?projectId=1 | ~1100ms | ~680ms |
| GET /api/activity/summary?projectId=1 | ~820ms | ~540ms |
Overall we saw roughly 30% lower latency across these endpoints. The UI felt noticeably snappier, especially when switching between projects and loading submission lists.
What I’d do next
- Add query result caching (e.g. Spring Cache + Redis) for summary endpoints that don’t change every second.
- Introduce observability — OpenTelemetry or Micrometer metrics to catch regressions early.
- Consider optimistic UI on the frontend so perceived latency drops even further.
If you’re working on a Spring Boot + PostgreSQL app and seeing slow list endpoints, start with indexes and join rewrites. They’re high-impact and don’t require new infrastructure.