Pagination
Never return an unbounded collection
GET /bookings returning current_user.bookings sounds harmless β until a long-time user has 4,000 bookings. Rendering all 4,000 as JSON blows up the response size, the memory used to build it, and the database query that fetched them. An UNBOUNDED collection is a liability that scales with how long someone's used your app, and a malicious or just enthusiastic client can make it worse by requesting the same big page over and over.
Offset pagination (page + per_page, using SQL LIMIT/OFFSET) is simple and what most APIs start with β you saw a basic version of it already in the bookings index. Its weakness shows up at scale: OFFSET 100000 still means the database walks past 100,000 rows before it can return page 100,001, and if a row gets inserted or deleted between two requests, the offset shifts and a client can see a row twice or skip one entirely.
Keyset (cursor) pagination fixes both problems: instead of "skip N rows," you ask for "rows after the last one I saw" β WHERE id > last_seen_id ORDER BY id LIMIT 20. The database can jump straight there using the index, no matter how deep you are, and rows don't shift under you since you're always anchored to a real id, not a position. The tradeoff is you can't jump to "page 50" directly β only walk forward from a cursor. For an API where clients scroll a feed instead of picking page numbers, keyset almost always wins at scale.
Whichever you pick, one rule is non-negotiable: always cap per_page. Without a cap, a client requesting per_page=999999 gets exactly the unbounded response you were trying to avoid β the cap is what actually enforces the limit, not the pagination scheme itself. Returning the current page (and ideally the total, or a has_more flag) in the response metadata lets the client know where it stands.