- Knowledge
- technology
- OOP
- Tips
- Programming
- Tips
- Tutorial
- SEO
- Ranking
- Knowledge
- Special Day
- Seo
- Bug
- Data science
- Seo
- artificial intelligence
- Machine Learning
- Robotics
- happyNewYear2021
- newYearEve
- 2021
- Automation
- Smart Home
- Career
- Best Practices
- Git
- Logging
- Web Fundamentals
- DNS
- HTTPS
- Performance
- AI Tools
- ChatGPT
- Claude
- Gemini
- Laravel
- Eloquent
- MySQL
- HTTPS
- TLS
- Web Security
- Certificates
- Developer Life
- Debugging
- Docker
- DevOps
- Transactions
- Queues
- LLMs
- AI
- AI Coding
- Developer Tools
- React Native
- Expo
- Kate PMS
- Mobile Apps
- Laravel
- Authentication
- Sanctum
- Cookies
- API Design
- Payments
- Idempotency
- DeepSeek
- Open Source AI
- LLMs
- AI News
- Git
- Version Control
- AI Coding
- Prompting
- PHP
- Checklist
- MCP
- AI Agents
- OpenAI
- Architecture
- Microservices
- Modular Monolith
- Estimation
- Developer Life
- Project Planning
- Humour
- OAuth
- OpenID Connect
- Authentication
- Embeddings
- Vector Search
- RAG
- pgvector
- OpenAI
- GPT-4.1
- Codex CLI
- Events
- Testing
- Clean Code
- Maintainability
- Code Review
- Webhooks
- API
- Security
- Claude Code
- Workflow
- AI
- LLM
- Prompt Injection
- Mobile
- React
- Networking
- TCP
- UDP
- HTTP/3
- CLAUDE.md
- AWS
- Cloud Security
- Backups
- PHPUnit
- Software Engineering
- Leadership
- Communication
- RAG
- Embeddings
- AI Engineering
- IT Infrastructure
- Networking
- Access Control
- CI/CD
- GitHub Actions
- Gemini CLI
- Claude Code
- JavaScript
- Async/Await
- Node.js
- Promises
- Security
- Cryptography
- Passwords
- MySQL
- Database
- Vibe Coding
- Software Quality
- DNS
- Code Reading
- Onboarding
- Productivity
- Background Jobs
- Developer Humour
- Estimates
- Dev Life
- JWT
- o3-mini
- DeepSeek R1
- Rate Limiting
- Kate PMS
- E-Signing
- Audit Trail
- REST
- GraphQL
- API Design
- Laravel 12
- Upgrade Guide
- Open Source
- Self-Hosting
- Task Scheduling
- Cron
- Secrets
- CORS
- PHP
- PHP-FPM
- OPcache
- GitHub Copilot
- Software Architecture
- Engineering
- TypeScript
- JavaScript
- Type Safety
- AI Security
- React Native
- Product Design
- AI Agents
- Kiro
- Queues
- Redis
- RabbitMQ
- AWS SQS
- Nginx
- Apache
- GPT-5
- gpt-oss
- Clean Code
- Architecture
- Naming
- Documentation
- Career
- ADR
- Teamwork
- Supply Chain
- Kate HRM
- HR Software
- Permissions
- System Design
- Pagination
- SSH
- Linux
- Big O
- Databases
- Laravel Boost
- MCP
- Developer Skills
- Validation
- Databases
- Indexes
- Code Quality
- Deployment
- Developer Humour
- Feature Flags
- Code Review
- Pull Requests
- Docker
- Cursor
- Authorization
- RBAC
- Gemini
- Long Context
- PHP 8.4
- Caching
- Dependency Injection
- Web Performance
- Browser
- CSS
- Database
- Migrations
- ChatGPT
- AI for Developers
- Monitoring
- On-Call
- REST
- Backend
- SQL
- NoSQL
- Database Design
- Coding Agents
- Claude 4
- API Resources
- REST API
- Load Balancing
- Scaling
- AWS
- AI Tools
- Claude
- Sora 2
- CTE
- 2FA
- TOTP
- Programming Languages
- Prompts
- Developer Workflow
- API Gateway
- APIs
- Passport
- API Auth
- Learning
- Burnout
- Developer Growth
- Web Development
- SEO
- Kate Mall
- ChatGPT Atlas
- Agent Skills
- Middleware
- Laravel 12
- Collections
- Context Window
- Monitoring
- Commit Messages
- Self Review
- Growth
- Regex
- Programming Basics
- Text Processing
- Database Design
- Normalization
- Linux
- Server Security
- Linux Foundation
- Open Standards
- Legacy Code
- Documentation
- AI Workflow
- File Uploads
- Test Data
- Hashing
- Performance
- Caching
- Enums
- Scope Creep
- Estimation
- Codex
- Gemini CLI
- Timezones
- Carbon
- Bugs
- PHP 8.5
- Gemini 3
- GPT-5.1
- Data Integrity
- Event Loop
- Async
- Opus 4.5
- AI Models
- React
- Forms
- Frontend
- Backups
- AI Images
- DALL-E
- Midjourney
- Race Conditions
- Concurrency
- Legacy Code
- Refactoring
- Senior Engineer
- Scope
- LLM
- CDN
- Web
- Sub-Agents
- Soft Deletes
- Audit Log
- Concurrency
- AI Learning
- NestJS
- AI Evals
- Policies
- SPF DKIM DMARC
- Unicode
- UTF-8
- Knowledge Graph
- Value Objects
- Technical Debt
- Feature Flags
- Laravel Pennant
- Deployment
- Copilot
- Composer
- Dependencies
- Artisan
- Automation
- AWS S3
- Object Storage
- Cloud
- Small Language Models
- Ollama
- Production
- Sessions
- HTTP
- Mentoring
- SQL
- Virtual Machines
- Web Development
- HTTP/2
- QUIC
- Web Performance
- AI Integration
- LLM API
- SOLID
- OOP
- Hosting
- Serverless
- Merge Conflicts
- Temperature
- AI Development
- Reverse Proxy
- Nginx
- Infrastructure
- Verification
- Passkeys
- WebAuthn
- Teams
- Communication
- Stakeholders
- Monorepo
- CI/CD
- Versioning
- JSON Schema
- Livewire
- Inertia
- Meetings
- Distributed Systems
- Privacy
- Full-Stack
- T-Shaped Skills
- Money
- Notifications
- Web Security
- HTTP Headers
- CSP
- Function Calling
- Load Testing
- k6
- Data Extraction
- Debugging
- WebSockets
- SSE
- Real-Time
- Laravel Reverb
- Infrastructure as Code
- Terraform
- Side Projects
- Laravel Pint
- OpenAPI
- Swagger
- UX
- Multimodal
- Jest
- Pair Programming
- APIs
- Rate Limiting
- Resilience
- Dev Humour
- Design Tokens
- JWT
- API Keys
- Sessions
- PHPStan
- Rector
- Incidents
- Reporting
- Dashboards
- Zero Trust
- IAM
- Search
- Laravel Scout
- Junior Developers
- Mentoring
- Images
- WebP
- AVIF
- Bug Reports
- Let's Encrypt
- Design Docs
- Software Design
- Observers
- Replication
- Accountability
- Data Structures
- Reliability
- LLM Memory
- Error Handling
- Payments
- Payment Gateway
- Webhooks
- PCI DSS
- Observability
- OpenTelemetry
- Personal Brand
- Writing
- Conventions
- Dates
- Scheduling
- Disaster Recovery
- Compression
- Brotli
- Deadlines
- Developer Habits
- State Machines
- Tech Roles
- UUID
- ULID
- Horizon
- Planning
- Engineering Culture
- Ownership
- Soft Skills
- Socialite
- Cost Control
- Collations
- Unicode
- Octane
- PostgreSQL
Pagination Done Right: Offset vs Cursor, and Why Page 40,000 Is So Slow
About Post
Pagination is one of those features that works perfectly in development. Twenty rows per page, a few hundred records in the seed data, instant responses.
Then the table grows into the millions, someone's script walks through every page of your API, and you notice that page 1 loads in a blink while page 40,000 takes long enough to make a cup of tea. Same query, same twenty rows. What changed?
The answer is OFFSET, and once you see how it works, you'll know exactly when to stop using it.
How offset pagination really works
Classic pagination is LIMIT and OFFSET. Page 3 with 20 per page is:
SELECT * FROM payments
ORDER BY id DESC
LIMIT 20 OFFSET 40;
It reads like "jump to row 40 and take 20". But databases can't jump. To skip 40 rows, MySQL has to find them, walk past them and throw them away. For page 3 that's nothing. For OFFSET 800000, it walks past eight hundred thousand rows to give you twenty.
It's like finding page 500 of a book that has no page numbers by counting every page from the start. The deeper you go, the slower it gets, and no index fixes the counting.
The second problem: rows that move
Offset has a quieter bug that has nothing to do with speed. Picture an infinite-scroll list of the newest payments:
- The app loads page 1: payments 1 to 20.
- While the user scrolls, three new payments arrive at the top.
- The app loads page 2 with
OFFSET 20. Everything has shifted down by three, so the user sees the last three payments of page 1 again.
Deletions do the opposite and silently skip rows. For a list on screen it's annoying. For a sync job or an export that walks through pages, it means missing or duplicated data, and nobody notices until the numbers don't add up.
Cursor pagination: "after this one"
Cursor pagination (also called keyset pagination) changes the question. Instead of "skip N rows", you say "give me the next 20 after the last row I saw":
SELECT * FROM payments
WHERE id < 918204 -- the last id from the previous page
ORDER BY id DESC
LIMIT 20;
With an index on id, the database goes straight to that position in the index and reads twenty rows. It doesn't matter if you're on the first page or the hundred-thousandth: the cost stays flat.
And because the position is anchored to a value rather than a count, new rows at the top don't push anything around. Page 2 always starts right after the last row you actually saw.
Sorting by something other than the id
Usually you sort by a date, and dates aren't unique. Two payments with the same created_at could be skipped or repeated at a page boundary. The fix is a tie-breaker: sort by the date and the id.
SELECT * FROM payments
WHERE created_at < ?
OR (created_at = ? AND id < ?)
ORDER BY created_at DESC, id DESC
LIMIT 20;
Back it with a composite index on (created_at, id), in that order, and this stays fast at any depth.
In Laravel: one method
You don't have to write that WHERE clause yourself. Laravel's cursorPaginate() builds it from your orderBy calls:
$payments = Payment::query()
->where('tenant_id', $tenant->id)
->orderByDesc('created_at')
->orderByDesc('id')
->cursorPaginate(20);
return PaymentResource::collection($payments);
Wrapped in an API resource like this, the response carries a links.next URL and a meta.next_cursor (return the paginator directly and you get next_page_url and next_cursor at the top level instead). Either way, the client simply follows the next link until it's null.
A few rules from the Laravel pagination docs worth knowing before you switch:
- The ordering must include at least one unique column (or a unique combination). That's why
idis there. - Columns used for ordering can't contain
NULLvalues. - You only get next and previous. There are no page numbers and no "jump to page 37".
- The cursor is an encoded string, not a secret. Treat it as opaque in the client, but don't put anything sensitive in the sort columns expecting it to be hidden.
Don't forget the hidden COUNT
There's one more cost people miss. Laravel's paginate() runs a second query, SELECT COUNT(*), to know how many pages exist. On a big table with filters, that count can be slower than the page itself.
If you need offset but don't need "page 3 of 4,812", use simplePaginate(). It skips the count and just checks whether there's a next page.
Which one should you use?
| Use case | My pick | Why |
|---|---|---|
| Admin table with page numbers, small or filtered data | paginate() | Users want numbered pages and totals; offset is fine at this size |
| Long list where totals don't matter | simplePaginate() | Offset without the COUNT query |
| Mobile infinite scroll, activity feeds | cursorPaginate() | No duplicates when new items arrive, flat cost |
| Public API, sync jobs, exports | cursorPaginate() or lazyById() | Clients walk every page; it must stay fast and consistent |
For jobs that process a whole table inside your own app, chunkById() and lazyById() use the same "after this id" idea under the hood. Plain chunk() uses offsets, and has the same skipping problem if you update the rows you're filtering on while iterating.
The rule of thumb: offset for humans clicking page numbers on modest data, cursors for machines and infinite scroll. If a client will ever walk through every page, design the API with cursors from day one, because changing pagination style later breaks every client.
The short version
OFFSETreads and discards every skipped row, so deep pages get slower.- Offset pages drift when rows are added or removed.
- Cursor pagination says "after this value", stays fast and stable, but has no page numbers.
- Always add a unique tie-breaker to the sort, and index the sort columns together.
paginate()also runs a COUNT.simplePaginate()doesn't.
Which style do your APIs use today, and have you ever had to migrate from one to the other?

Be first to comment it...