JOptimize’s Post

View organization page for JOptimize

731 followers

OFFSET pagination is fine… until page 10,000 Your SQL query returns 50 rows. So why does it get slower every week? Because the database is not optimizing for the 50 rows you keep. It is paying for the rows you skip. In a Java + Spring Boot API audit, I saw an admin endpoint paginating a large orders table like this: SELECT id, customer_id, status, created_at FROM orders ORDER BY created_at DESC LIMIT 50 OFFSET 500000; Application side looked harmless: PageRequest.of(page, 50, Sort.by(Sort.Direction.DESC, "createdAt")); At first, everything was fine. Then the table grew to around 12 million rows. And deep pagination became a real problem. 🚨 What happened For early pages: page 1: fast page 10: still fine page 100: acceptable But for deep pages, performance degraded badly: page 1 → ~40 ms page 1000 → ~220 ms page 10000 → ~1.8 s to 3 s Even though the API still returned only 50 rows. Why? Because with offset pagination, the database often has to: scan/index-walk through a huge number of rows discard the first N rows only then return the next 50 So OFFSET 500000 does not mean: “jump instantly to row 500001” It often means: “walk past 500000 rows, then give me 50” 💥 Real production impact On this endpoint: p95 latency kept increasing as data grew DB CPU spiked during heavy admin usage replicas got unnecessary read pressure users felt the app was “randomly slow” The worst part: it looked perfectly fine in dev with a small dataset. ❌ Typical Java code Page<Order> page = orderRepository.findAll( PageRequest.of(pageNumber, 50, Sort.by("createdAt").descending()) ); This is convenient. But on very large tables, convenience gets expensive. ✅ Better approach: keyset pagination Instead of asking for: page 10000 Ask for: the next 50 rows after this last seen value Example: SELECT id, customer_id, status, created_at FROM orders WHERE created_at < :lastCreatedAt ORDER BY created_at DESC LIMIT 50; And if created_at is not unique, use a tie-breaker: SELECT id, customer_id, status, created_at FROM orders WHERE (created_at, id) < (:lastCreatedAt, :lastId) ORDER BY created_at DESC, id DESC LIMIT 50; ✅ Why it scales better With a proper index like: INDEX (created_at DESC, id DESC) the database can seek directly to the next position. That means: no huge skip cost more stable latency much better behavior on large datasets ⚠️ Important nuance Offset pagination is not always wrong. It is often fine for: small tables internal tools with low volume shallow pagination cases where jumping directly to page 7 matters more than raw performance But for: millions of rows infinite scroll APIs under load deep historical navigation keyset pagination usually wins. 🧠 Takeaway OFFSET pagination does not fail when your query gets bigger. It fails when your data gets bigger. Are you still paginating large tables with LIMIT ... OFFSET ... in production? https://www.joptimize.io/ #JavaDev #SpringBoot #PostgreSQL #JavaPerformance #Backend

  • No alternative text description for this image

To view or add a comment, sign in

Explore content categories