Skip to content

Search and pagination

2 min read · main part only; folded sections are optional

After this you can answer: Why page with a cursor instead of OFFSET, and how does search-as-you-type stay cheap?

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"| Q
  1. Debounce. useEventSearch sets a 250 ms timer on every key press and clears the old one. Only quiet text starts a query.
  2. 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.
  3. 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.
  4. 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.

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 →

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.

SymptomCauseWhere to look
Old results flash over new onesThe query key did not include the textThe hook’s query input
Rows repeat or go missing between pagesThe cursor used time without the idThe ORDER BY and the cursor
Every key press hits the APIThe debounce timer is not cleareduseEventSearch step 1
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.

Answer these without scrolling up. If one feels hard, reread that section.

  1. Why wait 250 ms before searching?
  2. How can a slow old answer never overwrite a newer search?
  3. What two values make up the cursor, and why both?
  4. Why ask for 51 rows?
  5. 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/