Skip to content

perf(api): index saves by (user_id, created_at) for suggested-follows - #1022

Merged
dylanjeffers merged 1 commit into
mainfrom
perf/suggested-follows-saves-idx
Aug 18, 2026
Merged

perf(api): index saves by (user_id, created_at) for suggested-follows#1022
dylanjeffers merged 1 commit into
mainfrom
perf/suggested-follows-saves-idx

Conversation

@dylanjeffers

Copy link
Copy Markdown
Contributor

Follow-up to #1020. That endpoint measured 1.5–3.8s on a cold cache for users with large libraries on prod (cache hits are 50–280ms). This is the fix.

Cause

/v1/users/:id/suggested-follows takes the user's most recent favorites:

WHERE user_id = ? AND is_delete = false ORDER BY created_at DESC LIMIT ?

No existing index can serve that ordering:

  • saves_user_idx is (user_id, save_type, save_item_id, is_delete)created_at isn't in it.
  • saves_user_track_current_blocknumber_idx orders by blocknumber and is partial on save_type = 'track', and the endpoint also reads album/playlist saves.

So Postgres reads every save the user has and top-N sorts them.

reposts already has exactly the right index (reposts_user_created_at_active_idx, migration 0223) for the reposts half of the same query. This mirrors it for saves.

Measured

On a synthetic 50k-save user locally. The favorites CTE alone:

plan cost
before 50k-row parallel scan + top-N heapsort 4305..4535
after ordered index scan, stops at LIMIT 0.41..181 (0.5ms)

Whole query, 3 runs each:

config time
cap=2000, no index (shipped today) 17.7 / 18.0 / 19.1 ms
cap=2000, with index 8.5 / 8.6 / 12.7 ms
cap=500, with index 1.9 / 1.9 / 2.2 ms

Local absolutes are far below prod's because my dataset is tiny by comparison — but prod is precisely where "read all the user's saves" hurts most, so the win should be considerably larger there.

Why the cap is unchanged

Lowering suggestedFollowsEngagementCap is the other available lever and the table above shows it's worth ~4.5x on top of the index. I'm not taking it.

Without the index there's a hard floor around 9ms no matter how low the cap goes — that floor is the full scan, so the cap can't fix the actual problem. And the cap bounds the candidate pool before already-followed artists are filtered out, so a user who follows most of the artists they recently engaged with ends up with a short list rather than a full page. I hit exactly that while testing (cap=500 returned 1 result instead of 10) — that specific case was an artifact of my seed data, which had made 499 of the 500 candidates already-followed, but the mechanism is real and I haven't quantified it against production follow ratios.

The index gets the fix without that trade. A comment in the handler records the reasoning.

Rollout

CREATE INDEX CONCURRENTLY IF NOT EXISTS, same as 0223. Verified it applies to a fresh schema and is idempotent on re-run. saves is a high-write table, so worth an eye on write latency after it lands — the index is ~24% of table size on my synthetic data.

🤖 Generated with Claude Code

/v1/users/:id/suggested-follows takes the user's most recent favorites via
`WHERE user_id = ? AND is_delete = false ORDER BY created_at DESC LIMIT ?`,
and no existing index can serve that ordering: saves_user_idx orders by
save_type and save_item_id before created_at, and
saves_user_track_current_blocknumber_idx orders by blocknumber and is partial
on save_type = 'track' (the endpoint also reads album/playlist saves). So
Postgres reads every one of the user's saves and top-N sorts them.

Measured on prod after the endpoint shipped: cache hits 50-280ms, cold misses
1.5-3.8s for users with large libraries.

This mirrors reposts_user_created_at_active_idx (migration 0223), which
already solves the identical problem for the reposts half of the same query.

On a synthetic 50k-save user the CTE goes from a 50k-row parallel scan +
top-N heapsort (cost 4305..4535, ~9ms floor regardless of LIMIT) to a plain
ordered index scan that stops at the limit (cost 0.41..181, 0.5ms). Whole
query: ~18ms -> ~9ms locally, and the win should be far larger on prod where
the scan is over a vastly bigger table.

Deliberately does NOT lower suggestedFollowsEngagementCap. Lowering it does
help -- with the index, cap=500 measured ~2ms vs ~9ms at 2000 -- but the cap
bounds the candidate pool *before* already-followed artists are filtered out,
so a user who follows most of the artists they recently engaged with would
get a short list. That trade isn't worth making when the index removes the
reason for it. Comment added recording the tradeoff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dylanjeffers
dylanjeffers merged commit ded9347 into main Aug 18, 2026
2 checks passed
@dylanjeffers
dylanjeffers deleted the perf/suggested-follows-saves-idx branch August 18, 2026 22:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant