Description
GET /contacts?search= performs a case-insensitive substring match (email ILIKE '%term%') that no existing index can serve, so PostgreSQL evaluates the pattern against every contact row in the project on every request.
Three compounding causes:
-
ILIKE '%term%' is not sargable. The indexes on contacts are (projectId, email) unique, (projectId), (projectId, subscribed), (snoozedUntil) and a GIN on data. None can satisfy a leading-wildcard case-insensitive match, so every row in the project is scanned and case-folded — CPU-bound work, not I/O.
-
No index covers the sort. ORDER BY "createdAt" DESC, id DESC has no supporting index, so the LIMIT 21 cannot early-exit via an ordered index walk. The full match set is materialised and top-N sorted. This also affects the unfiltered contact list — the page every user lands on.
-
The first page also runs an uncapped COUNT. ContactService.ts:119 computes total on every first page. Same predicate, no LIMIT, so no early exit is possible. Because changing the search resets pagination to page 1, every debounced keystroke runs both queries.
Search is debounced but has no minimum length on the contacts page, so a single character (a) matches nearly every contact — the worst case for both the scan and the sort.
This is a pre-existing endpoint degrading with data growth, not a regression from a specific release.
To Reproduce
Prerequisite: a project with a large contact count. The effect is proportional to contacts-per-project and is not observable on a small dataset. Measurements below used 2,000,000 contacts in one project (2.4M rows total across tenants).
Path A — Contacts page search (primary, highest frequency)
- Log in and open Contacts (
/contacts).
- Open browser DevTools → Network, filter to
contacts.
- Type a single common character into the "Search by email…" box — e.g.
a.
- Observe the
GET /contacts?limit=…&search=a request.
Observed: the request takes hundreds of ms to multiple seconds, and server CPU spikes for its duration. Note this page enforces no minimum search length, so one character is enough to trigger it.
- Try a common substring instead — e.g.
gmail.com. Same behaviour, worst case.
- Clear the search box entirely. The unfiltered list request is also slow, since no index covers the default
createdAt DESC ordering.
Path B — Command palette
- Press ⌘K / Ctrl+K from anywhere in the dashboard.
- Type any single character.
- Network tab shows
GET /contacts?search=<char>&limit=5.
Fires at 1 character with a 200 ms debounce — the shortest of the three surfaces.
Path C — Segment contact picker
- Go to Segments → create a new one or open an existing static segment (
/segments/new or /segments/[id]).
- Use the "Add contacts" picker in search mode.
- Type into "Type an email to search…".
- Network tab shows
GET /contacts?limit=20&search=….
Path D — Bulk action over a search result
- On
/contacts, enter a search term matching many contacts (e.g. gmail.com).
- Select all rows, then choose "select all matching".
- Trigger a bulk subscribe / unsubscribe / delete.
This sends a query-mode selector, and bulk-contact-processor.ts:106 re-runs the same uncapped COUNT server-side before iterating. Once per job rather than per keystroke, but against the same unindexable predicate.
Path E — Direct API (no UI)
time curl -s -H "Authorization: Bearer $SECRET_KEY" \
"$API_URI/contacts?limit=20&search=gmail.com" -o /dev/null
Confirming the cause at the database level
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, email, data, subscribed, "projectId", "createdAt", "updatedAt"
FROM contacts
WHERE "projectId" = '<project-id>' AND email ILIKE '%gmail.com%'
ORDER BY "createdAt" DESC, id DESC
LIMIT 21 OFFSET 0;
Expect a Seq Scan (or full index scan) with a large Rows Removed by Filter, plus a top-N heapsort.
Expected behavior
Contact search returns the first page well under the project's stated target of < 200 ms for read operations (CLAUDE.md, "Scale & Performance Requirements"), and cost should not scale linearly with the total number of contacts in the project.
Specifically:
- The substring filter should be index-served (e.g. a
pg_trgm GIN index), not a per-row scan.
- The default
createdAt DESC ordering should be an ordered index walk with early exit at the page limit, rather than a full sort — this applies to the unfiltered list too.
- The first-page total should not require an uncapped scan of every matching row.
Environment
Please select the option that applies:
If self-hosted, please provide relevant details (Docker, Kubernetes, bare metal, etc.):
Reproduced against a synthetic dataset on the schema at the commit below, using the stock (unmodified) query path:
- PostgreSQL 16.15 (Debian), Docker,
en_US.utf8 collation
shared_buffers=2GB, work_mem=64MB, max_parallel_workers_per_gather=2
- 2.4M
contacts rows, 2.0M of them in the queried project
- Warm cache; medians of 5 runs
The dataset is synthetic (generated addresses, ~45% gmail.com to give a realistic low-selectivity term). Absolute numbers are specific to that corpus; the shape of the problem is what matters.
Version verification
⚠️ Important:
Self-hosted issues must be reproduced on the latest commit.
Issues without a confirmed commit SHA may be closed without investigation.
If self-hosted:
This is the current tip of next at the time of filing.
Logs / Error output
No errors are produced — the endpoint returns HTTP 200. The symptom is latency and CPU consumption. Query plans from the reproduction:
-- Page query: parallel seq scan + top-N heapsort over the whole project
Limit (cost=141055.21..141057.66 rows=21 width=160) (actual time=381.828..392.770 rows=21 loops=1)
Buffers: shared hit=123941
-> Gather Merge (actual time=377.461..388.402 rows=21 loops=1)
Workers Planned: 2 Workers Launched: 2
-> Sort (actual time=364.756..364.759 rows=16 loops=3)
Sort Key: "createdAt" DESC, id DESC
Sort Method: top-N heapsort Memory: 34kB
-> Parallel Seq Scan on contacts (actual time=27.147..364.602 rows=71 loops=3)
Filter: ((email ~~* '%elena.pons%'::text) AND ("projectId" = 'proj_big'::text))
Rows Removed by Filter: 866572
Planning Time: 0.744 ms
-- Count query: index-only scan, but no LIMIT so it can never early-exit
Aggregate (cost=42848.08..42848.09 rows=1) (actual time=331.001..336.554 rows=1 loops=1)
Buffers: shared hit=946403
-> Gather (actual time=97.377..336.536 rows=212 loops=1)
Workers Planned: 2 Workers Launched: 2
-> Parallel Index Only Scan using "contacts_projectId_email_key" on contacts
(actual time=95.398..328.952 rows=71 loops=3)
Index Cond: ("projectId" = 'proj_big'::text)
Filter: (email ~~* '%elena.pons%'::text)
Rows Removed by Filter: 666596
Heap Fetches: 0
Execution Time: 336.592 ms
Measured medians (5 runs each, EXPLAIN ANALYZE execution time, 2M contacts in the project, warm cache):
| search term |
matches |
page query |
count query |
gmail.com |
900k (45%) |
448 ms |
902 ms |
ez (2 chars) |
320k (16%) |
368 ms |
776 ms |
nguyen |
40k (2%) |
364 ms |
406 ms |
martinez |
40k (2%) |
362 ms |
345 ms |
elena.pons |
212 (0.01%) |
363 ms |
360 ms |
zzqx |
0 |
340 ms |
344 ms |
| (no search — default list) |
— |
213 ms |
— |
Cost is roughly flat regardless of how many rows actually match — including a term matching nothing — which is consistent with a full scan rather than a lookup.
Screenshots / Recordings / Additional context
Relevant code (line numbers at 1df7bfb):
apps/api/src/services/ContactService.ts:88-89 — contains + mode: 'insensitive' → ILIKE
apps/api/src/services/ContactService.ts:102-103 — orderBy [{createdAt}, {id}], no covering index
apps/api/src/services/ContactService.ts:119 — uncapped first-page count()
apps/api/src/jobs/bulk-contact-processor.ts:35,106 — same predicate in the bulk path
apps/api/src/controllers/Contacts.ts:75 — endpoint
apps/web/src/pages/contacts/index.tsx:179-185 — 350 ms debounce, no minimum length
apps/web/src/components/CommandPalette.tsx:161 — 200 ms debounce, fires at 1 char
apps/web/src/components/ContactPicker.tsx:83 — 300 ms debounce, fires at 1 char
Also affected by the same missing index (not search-specific): the unfiltered contact list, and SegmentService.buildStringFieldCondition email filters, which run over every contact in a project during segment evaluation.
Not affected: CSV import and API contact creation. Those use exact lookups (findByEmail / upsert) against the (projectId, email) btree.
Note on possible fixes: a pg_trgm GIN index on email addresses the substring filter, but only for terms selective enough to be worth a bitmap — it does nothing for common terms like gmail.com, where the planner correctly declines it. An index on ("projectId", "createdAt" DESC, id DESC) is what turns the page query into an early-exit ordered walk, and is the larger lever for the list itself. The uncapped COUNT is not fixable by indexing alone. Happy to open a PR if the maintainers would like one.
Description
GET /contacts?search=performs a case-insensitive substring match (email ILIKE '%term%') that no existing index can serve, so PostgreSQL evaluates the pattern against every contact row in the project on every request.Three compounding causes:
ILIKE '%term%'is not sargable. The indexes oncontactsare(projectId, email)unique,(projectId),(projectId, subscribed),(snoozedUntil)and a GIN ondata. None can satisfy a leading-wildcard case-insensitive match, so every row in the project is scanned and case-folded — CPU-bound work, not I/O.No index covers the sort.
ORDER BY "createdAt" DESC, id DESChas no supporting index, so theLIMIT 21cannot early-exit via an ordered index walk. The full match set is materialised and top-N sorted. This also affects the unfiltered contact list — the page every user lands on.The first page also runs an uncapped
COUNT.ContactService.ts:119computestotalon every first page. Same predicate, noLIMIT, so no early exit is possible. Because changing the search resets pagination to page 1, every debounced keystroke runs both queries.Search is debounced but has no minimum length on the contacts page, so a single character (
a) matches nearly every contact — the worst case for both the scan and the sort.This is a pre-existing endpoint degrading with data growth, not a regression from a specific release.
To Reproduce
Prerequisite: a project with a large contact count. The effect is proportional to contacts-per-project and is not observable on a small dataset. Measurements below used 2,000,000 contacts in one project (2.4M rows total across tenants).
Path A — Contacts page search (primary, highest frequency)
/contacts).contacts.a.GET /contacts?limit=…&search=arequest.Observed: the request takes hundreds of ms to multiple seconds, and server CPU spikes for its duration. Note this page enforces no minimum search length, so one character is enough to trigger it.
gmail.com. Same behaviour, worst case.createdAt DESCordering.Path B — Command palette
GET /contacts?search=<char>&limit=5.Fires at 1 character with a 200 ms debounce — the shortest of the three surfaces.
Path C — Segment contact picker
/segments/newor/segments/[id]).GET /contacts?limit=20&search=….Path D — Bulk action over a search result
/contacts, enter a search term matching many contacts (e.g.gmail.com).This sends a query-mode selector, and
bulk-contact-processor.ts:106re-runs the same uncappedCOUNTserver-side before iterating. Once per job rather than per keystroke, but against the same unindexable predicate.Path E — Direct API (no UI)
Confirming the cause at the database level
Expect a
Seq Scan(or full index scan) with a largeRows Removed by Filter, plus atop-N heapsort.Expected behavior
Contact search returns the first page well under the project's stated target of < 200 ms for read operations (
CLAUDE.md, "Scale & Performance Requirements"), and cost should not scale linearly with the total number of contacts in the project.Specifically:
pg_trgmGIN index), not a per-row scan.createdAt DESCordering should be an ordered index walk with early exit at the page limit, rather than a full sort — this applies to the unfiltered list too.Environment
Please select the option that applies:
Deployment type:
If self-hosted, please provide relevant details (Docker, Kubernetes, bare metal, etc.):
Reproduced against a synthetic dataset on the schema at the commit below, using the stock (unmodified) query path:
en_US.utf8collationshared_buffers=2GB,work_mem=64MB,max_parallel_workers_per_gather=2contactsrows, 2.0M of them in the queried projectThe dataset is synthetic (generated addresses, ~45%
gmail.comto give a realistic low-selectivity term). Absolute numbers are specific to that corpus; the shape of the problem is what matters.Version verification
If self-hosted:
I have confirmed this issue still exists on the latest commit (not the
latesttag)1df7bfb3a5c6bb5bf99301daa5dda1e8a80d2655This is the current tip of
nextat the time of filing.Logs / Error output
No errors are produced — the endpoint returns HTTP 200. The symptom is latency and CPU consumption. Query plans from the reproduction:
Measured medians (5 runs each,
EXPLAIN ANALYZEexecution time, 2M contacts in the project, warm cache):gmail.comez(2 chars)nguyenmartinezelena.ponszzqxCost is roughly flat regardless of how many rows actually match — including a term matching nothing — which is consistent with a full scan rather than a lookup.
Screenshots / Recordings / Additional context
Relevant code (line numbers at
1df7bfb):apps/api/src/services/ContactService.ts:88-89—contains+mode: 'insensitive'→ILIKEapps/api/src/services/ContactService.ts:102-103—orderBy [{createdAt}, {id}], no covering indexapps/api/src/services/ContactService.ts:119— uncapped first-pagecount()apps/api/src/jobs/bulk-contact-processor.ts:35,106— same predicate in the bulk pathapps/api/src/controllers/Contacts.ts:75— endpointapps/web/src/pages/contacts/index.tsx:179-185— 350 ms debounce, no minimum lengthapps/web/src/components/CommandPalette.tsx:161— 200 ms debounce, fires at 1 charapps/web/src/components/ContactPicker.tsx:83— 300 ms debounce, fires at 1 charAlso affected by the same missing index (not search-specific): the unfiltered contact list, and
SegmentService.buildStringFieldConditionemail filters, which run over every contact in a project during segment evaluation.Not affected: CSV import and API contact creation. Those use exact lookups (
findByEmail/upsert) against the(projectId, email)btree.Note on possible fixes: a
pg_trgmGIN index onemailaddresses the substring filter, but only for terms selective enough to be worth a bitmap — it does nothing for common terms likegmail.com, where the planner correctly declines it. An index on("projectId", "createdAt" DESC, id DESC)is what turns the page query into an early-exit ordered walk, and is the larger lever for the list itself. The uncappedCOUNTis not fixable by indexing alone. Happy to open a PR if the maintainers would like one.