Search and pagination
After this you can answer: Why page with a cursor instead of OFFSET, and how does search-as-you-type stay cheap?
Picture it
Section titled “Picture it”flowchart TB T["user types"] -->|"250 ms quiet"| Q["events.search<br/>q, filters, limit 50"] Q --> SQL["WHERE (starts_at, id) > cursor<br/>ORDER BY starts_at, id<br/>LIMIT 51"] SQL --> R["50 events + nextCursor"] R -->|"scroll"| QHow it works
Section titled “How it works”- Debounce.
useEventSearchsets a 250 ms timer on every key press and clears the old one. Only quiet text starts a query. - One query key per input. Each search text has its own cache key, and an old request is aborted. Even if a server ignored the abort, the old answer could only fill its own key.
- Keyset pagination. The query asks for rows after
(startsAt, id)of the last row, ordered by both. The id breaks ties between events at the same time. - One extra row. It fetches 51 rows for a page of 50. A 51st row means there is a next page; no costly count query is needed.
In MiniTickets
Section titled “In MiniTickets”The SQL is in apps/api/src/queries/event-search.ts → search. The text
matches title || ' ' || venue with ILIKE, served by a trigram GIN index, and %, _ and \ in
the text are escaped, so they mean themselves. The input schema caps the text at 200 characters and
the page at 100 (default 50) (packages/shared/src/schemas/event-search.ts → SearchEventsInput).
The cursor also carries asOf, the time the first page was asked, so the list of upcoming events
does not shift while you scroll.
Measured locally: 10,000 events paged as 100 pages with 0 duplicates in 265 ms. The browser renders only the visible cards (about 6 mounted at a time).
Play “Search as you type” on the map →See it in AWS
Section titled “See it in AWS”Search runs in the api tasks and Postgres. RDS → Performance Insights (if enabled) would show the
query; the API log shows each events.search call.
aws logs tail /minitickets/dev/api --since 10m --filter-pattern events.search
Break it
Section titled “Break it”| Symptom | Cause | Where to look |
|---|---|---|
| Old results flash over new ones | The query key did not include the text | The hook’s query input |
| Rows repeat or go missing between pages | The cursor used time without the id | The ORDER BY and the cursor |
| Every key press hits the API | The debounce timer is not cleared | useEventSearch step 1 |
Say it in an interview
Section titled “Say it in an interview”Follow-ups
Section titled “Follow-ups”What is wrong with OFFSET?
OFFSET 5000 makes Postgres read and throw away 5,000 rows first, so each page gets slower. And if
a row is inserted while you page, OFFSET shows a row twice or skips one.
What can a cursor not do?
Jump to page 37 directly. Our list is an endless scroll, so that is fine. A numbered page picker would need OFFSET or a different design.
Why a trigram index?
A B-tree index helps only with “starts with”. Searching “contains pune” needs a trigram GIN index, which indexes three-letter pieces of the text.
Advanced: Why search skips the list caches
Every search text and cursor would make its own cache key, so caching them would mostly store
entries nobody reads again. Search therefore goes to Postgres each time, and the indexes keep it
cheap. The older events.list keeps its caches.
Checkpoint
Section titled “Checkpoint”Answer these without scrolling up. If one feels hard, reread that section.
- Why wait 250 ms before searching?
- How can a slow old answer never overwrite a newer search?
- What two values make up the cursor, and why both?
- Why ask for 51 rows?
- Why does page 100 cost the same as page 1?
Redraw it from memory: typing → 250 ms → events.search → keyset SQL with LIMIT 51 → 50 rows + nextCursor
Try this: on the map, play the search flow and read the last step about aborts and query keys
Read this book online: https://book.theversehub.in/