Infinite Scroll with Convex
This guide explains how to use @humanspeak/svelte-virtual-list with Convex for real-time data and infinite scroll pagination.
Overview
This pattern combines:
- Real-time subscriptions - First page updates live via Convex WebSocket
- Infinite scroll pagination - Older pages loaded on-demand via one-time queries
- Virtualization - Only visible items are rendered in the DOM
Architecture
┌─────────────────────────────────────────────────────────┐
│ VirtualList │
│ ┌───────────────────────────────────────────────────┐ │
│ │ Live Data (useQuery - WebSocket subscription) │ │
│ │ - First page of items │ │
│ │ - Updates in real-time when data changes │ │
│ └───────────────────────────────────────────────────┘ │
│ ┌───────────────────────────────────────────────────┐ │
│ │ Paginated Data (client.query - one-time fetch) │ │
│ │ - Loaded when user scrolls near bottom │ │
│ │ - Uses cursor-based pagination │ │
│ └───────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘┌─────────────────────────────────────────────────────────┐
│ VirtualList │
│ ┌───────────────────────────────────────────────────┐ │
│ │ Live Data (useQuery - WebSocket subscription) │ │
│ │ - First page of items │ │
│ │ - Updates in real-time when data changes │ │
│ └───────────────────────────────────────────────────┘ │
│ ┌───────────────────────────────────────────────────┐ │
│ │ Paginated Data (client.query - one-time fetch) │ │
│ │ - Loaded when user scrolls near bottom │ │
│ │ - Uses cursor-based pagination │ │
│ └───────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘Setup
1. Install Dependencies
pnpm add convex convex-svelte @humanspeak/svelte-virtual-listpnpm add convex convex-svelte @humanspeak/svelte-virtual-list2. Environment Variables
PUBLIC_CONVEX_URL=https://your-deployment.convex.cloudPUBLIC_CONVEX_URL=https://your-deployment.convex.cloud3. Initialize Convex Client
Create src/lib/convex.ts:
import { env } from '$env/dynamic/public'
import { setupConvex, useConvexClient } from 'convex-svelte'
export const setup = () => {
if (env.PUBLIC_CONVEX_URL) {
setupConvex(env.PUBLIC_CONVEX_URL, {
unsavedChangesWarning: false
})
}
}
export const getConvexClient = () => useConvexClient()import { env } from '$env/dynamic/public'
import { setupConvex, useConvexClient } from 'convex-svelte'
export const setup = () => {
if (env.PUBLIC_CONVEX_URL) {
setupConvex(env.PUBLIC_CONVEX_URL, {
unsavedChangesWarning: false
})
}
}
export const getConvexClient = () => useConvexClient()Call setup() in your root layout:
<!-- src/routes/+layout.svelte -->
<script lang="ts">
import { setup } from '$lib/convex'
setup()
let { children } = $props()
</script>
{@render children()}<!-- src/routes/+layout.svelte -->
<script lang="ts">
import { setup } from '$lib/convex'
setup()
let { children } = $props()
</script>
{@render children()}Convex Backend
Real-time Query (for live first page)
// convex/items.ts
import { query } from './_generated/server'
import { v } from 'convex/values'
export const listRecent = query({
args: { limit: v.optional(v.number()) },
handler: async (ctx, args) => {
const limit = args.limit ?? 50
return await ctx.db
.query('items')
.order('desc')
.take(limit)
}
})// convex/items.ts
import { query } from './_generated/server'
import { v } from 'convex/values'
export const listRecent = query({
args: { limit: v.optional(v.number()) },
handler: async (ctx, args) => {
const limit = args.limit ?? 50
return await ctx.db
.query('items')
.order('desc')
.take(limit)
}
})Paginated Query (for infinite scroll)
// convex/items.ts
export const listPaginated = query({
args: {
cursor: v.optional(v.number()),
limit: v.optional(v.number())
},
handler: async (ctx, args) => {
const limit = args.limit ?? 50
const cursor = args.cursor
let queryBuilder = ctx.db.query('items')
if (cursor !== undefined) {
queryBuilder = queryBuilder.filter((q) =>
q.lt(q.field('_creationTime'), cursor)
)
}
const items = await queryBuilder
.order('desc')
.take(limit + 1)
const hasMore = items.length > limit
const pageItems = hasMore ? items.slice(0, limit) : items
return {
items: pageItems,
hasMore,
nextCursor: pageItems.length > 0
? pageItems[pageItems.length - 1]._creationTime
: null
}
}
})// convex/items.ts
export const listPaginated = query({
args: {
cursor: v.optional(v.number()),
limit: v.optional(v.number())
},
handler: async (ctx, args) => {
const limit = args.limit ?? 50
const cursor = args.cursor
let queryBuilder = ctx.db.query('items')
if (cursor !== undefined) {
queryBuilder = queryBuilder.filter((q) =>
q.lt(q.field('_creationTime'), cursor)
)
}
const items = await queryBuilder
.order('desc')
.take(limit + 1)
const hasMore = items.length > limit
const pageItems = hasMore ? items.slice(0, limit) : items
return {
items: pageItems,
hasMore,
nextCursor: pageItems.length > 0
? pageItems[pageItems.length - 1]._creationTime
: null
}
}
})Frontend Implementation
Complete Example
<script lang="ts">
import { useQuery, useConvexClient } from 'convex-svelte'
import { api } from '$lib/convex/api'
import VirtualList from '@humanspeak/svelte-virtual-list'
type Item = {
_id: string
_creationTime: number
name: string
}
// Props from server-side load function (for SSR)
let { data } = $props()
// Real-time subscription for first page
const liveQuery = useQuery(
api.items.listRecent,
{ limit: 50 },
{ initialData: data.items }
)
// Convex client for pagination queries
const client = useConvexClient()
// Pagination state
let olderItems = $state<Item[]>([])
let cursor = $state<number | null>(null)
let hasMore = $state(true)
// Combine live data with paginated data
const items = $derived.by(() => {
const live = (liveQuery.data ?? data.items) as Item[]
return [...live, ...olderItems]
})
// Update cursor when live data changes
$effect(() => {
const live = liveQuery.data as Item[] | undefined
if (live && live.length > 0) {
cursor = live[live.length - 1]._creationTime
}
})
// Load more function for infinite scroll
async function loadMore() {
if (!hasMore || !cursor || !client) return
const result = await client.query(
api.items.listPaginated,
{ cursor, limit: 50 }
)
olderItems = [...olderItems, ...result.items]
cursor = result.nextCursor
hasMore = result.hasMore
}
// Live indicator
const isLive = $derived(
!liveQuery.isLoading && !liveQuery.error
)
</script>
{#if isLive}
<div class="live-indicator">
<span class="pulse"></span>
Live
</div>
{/if}
<VirtualList
{items}
defaultEstimatedItemHeight={60}
onLoadMore={loadMore}
{hasMore}
>
{#snippet renderItem(item: Item)}
<div class="item">{item.name}</div>
{/snippet}
</VirtualList>
<style>
.live-indicator {
display: flex;
align-items: center;
gap: 6px;
color: #16a34a;
}
.pulse {
width: 8px;
height: 8px;
background: #22c55e;
border-radius: 50%;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style><script lang="ts">
import { useQuery, useConvexClient } from 'convex-svelte'
import { api } from '$lib/convex/api'
import VirtualList from '@humanspeak/svelte-virtual-list'
type Item = {
_id: string
_creationTime: number
name: string
}
// Props from server-side load function (for SSR)
let { data } = $props()
// Real-time subscription for first page
const liveQuery = useQuery(
api.items.listRecent,
{ limit: 50 },
{ initialData: data.items }
)
// Convex client for pagination queries
const client = useConvexClient()
// Pagination state
let olderItems = $state<Item[]>([])
let cursor = $state<number | null>(null)
let hasMore = $state(true)
// Combine live data with paginated data
const items = $derived.by(() => {
const live = (liveQuery.data ?? data.items) as Item[]
return [...live, ...olderItems]
})
// Update cursor when live data changes
$effect(() => {
const live = liveQuery.data as Item[] | undefined
if (live && live.length > 0) {
cursor = live[live.length - 1]._creationTime
}
})
// Load more function for infinite scroll
async function loadMore() {
if (!hasMore || !cursor || !client) return
const result = await client.query(
api.items.listPaginated,
{ cursor, limit: 50 }
)
olderItems = [...olderItems, ...result.items]
cursor = result.nextCursor
hasMore = result.hasMore
}
// Live indicator
const isLive = $derived(
!liveQuery.isLoading && !liveQuery.error
)
</script>
{#if isLive}
<div class="live-indicator">
<span class="pulse"></span>
Live
</div>
{/if}
<VirtualList
{items}
defaultEstimatedItemHeight={60}
onLoadMore={loadMore}
{hasMore}
>
{#snippet renderItem(item: Item)}
<div class="item">{item.name}</div>
{/snippet}
</VirtualList>
<style>
.live-indicator {
display: flex;
align-items: center;
gap: 6px;
color: #16a34a;
}
.pulse {
width: 8px;
height: 8px;
background: #22c55e;
border-radius: 50%;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style>Server-Side Data Loading (SSR)
For SSR, use ConvexHttpClient since convex-svelte only works on the client:
// +page.server.ts
import { env } from '$env/dynamic/public'
import { api } from '$lib/convex/api'
import { ConvexHttpClient } from 'convex/browser'
import type { PageServerLoad } from './$types'
export const load: PageServerLoad = async () => {
const client = new ConvexHttpClient(env.PUBLIC_CONVEX_URL!)
const items = await client.query(
api.items.listRecent,
{ limit: 50 }
)
return { items }
}// +page.server.ts
import { env } from '$env/dynamic/public'
import { api } from '$lib/convex/api'
import { ConvexHttpClient } from 'convex/browser'
import type { PageServerLoad } from './$types'
export const load: PageServerLoad = async () => {
const client = new ConvexHttpClient(env.PUBLIC_CONVEX_URL!)
const items = await client.query(
api.items.listRecent,
{ limit: 50 }
)
return { items }
}Chat UI Pattern (Bottom-to-Top)
For chat and messaging UIs, use bottomToTop mode so the newest message appears at the bottom and scrolling up loads older messages.
Chat Backend with Composite Index
Chat queries typically filter by conversation or channel. Define a composite index for efficient lookups:
// convex/schema.ts
import { defineSchema, defineTable } from 'convex/server'
import { v } from 'convex/values'
export default defineSchema({
messages: defineTable({
conversation: v.id('conversations'),
content: v.string(),
isDeleted: v.optional(v.number()),
createdAt: v.number(),
updatedAt: v.number()
}).index('by_conversation', ['conversation', 'isDeleted'])
})// convex/schema.ts
import { defineSchema, defineTable } from 'convex/server'
import { v } from 'convex/values'
export default defineSchema({
messages: defineTable({
conversation: v.id('conversations'),
content: v.string(),
isDeleted: v.optional(v.number()),
createdAt: v.number(),
updatedAt: v.number()
}).index('by_conversation', ['conversation', 'isDeleted'])
})Use the index in both the live and paginated queries:
// convex/messages.ts
import { query } from './_generated/server'
import { v } from 'convex/values'
export const listByConversation = query({
args: {
conversation: v.id('conversations'),
limit: v.optional(v.number())
},
handler: async (ctx, args) => {
const limit = args.limit ?? 50
return await ctx.db
.query('messages')
.withIndex('by_conversation', (q) =>
q.eq('conversation', args.conversation).eq('isDeleted', undefined)
)
.order('desc')
.take(limit)
}
})
export const listByConversationPaginated = query({
args: {
conversation: v.id('conversations'),
cursor: v.optional(v.number()),
limit: v.optional(v.number())
},
handler: async (ctx, args) => {
const limit = args.limit ?? 50
const cursor = args.cursor
let queryBuilder = ctx.db
.query('messages')
.withIndex('by_conversation', (q) =>
q.eq('conversation', args.conversation).eq('isDeleted', undefined)
)
if (cursor !== undefined) {
queryBuilder = queryBuilder.filter((q) =>
q.lt(q.field('_creationTime'), cursor)
)
}
const items = await queryBuilder.order('desc').take(limit + 1)
const hasMore = items.length > limit
const pageItems = hasMore ? items.slice(0, limit) : items
return {
items: pageItems,
hasMore,
nextCursor: pageItems.length > 0
? pageItems[pageItems.length - 1]._creationTime
: null
}
}
})// convex/messages.ts
import { query } from './_generated/server'
import { v } from 'convex/values'
export const listByConversation = query({
args: {
conversation: v.id('conversations'),
limit: v.optional(v.number())
},
handler: async (ctx, args) => {
const limit = args.limit ?? 50
return await ctx.db
.query('messages')
.withIndex('by_conversation', (q) =>
q.eq('conversation', args.conversation).eq('isDeleted', undefined)
)
.order('desc')
.take(limit)
}
})
export const listByConversationPaginated = query({
args: {
conversation: v.id('conversations'),
cursor: v.optional(v.number()),
limit: v.optional(v.number())
},
handler: async (ctx, args) => {
const limit = args.limit ?? 50
const cursor = args.cursor
let queryBuilder = ctx.db
.query('messages')
.withIndex('by_conversation', (q) =>
q.eq('conversation', args.conversation).eq('isDeleted', undefined)
)
if (cursor !== undefined) {
queryBuilder = queryBuilder.filter((q) =>
q.lt(q.field('_creationTime'), cursor)
)
}
const items = await queryBuilder.order('desc').take(limit + 1)
const hasMore = items.length > limit
const pageItems = hasMore ? items.slice(0, limit) : items
return {
items: pageItems,
hasMore,
nextCursor: pageItems.length > 0
? pageItems[pageItems.length - 1]._creationTime
: null
}
}
})Chat Frontend with Optimistic Updates
Chat UIs benefit from optimistic updates — showing the user’s message instantly before the server confirms it. This requires a three-layer data merge:
<script lang="ts">
import { useQuery, useConvexClient } from 'convex-svelte'
import { api } from '$lib/convex/api'
import VirtualList from '@humanspeak/svelte-virtual-list'
type Message = {
_id: string
_creationTime: number
conversation: string
content: string
nonce?: string
}
let { conversationId } = $props()
const liveQuery = useQuery(
api.messages.listByConversation,
{ conversation: conversationId, limit: 50 }
)
const client = useConvexClient()
// Optimistic messages shown before server confirms
let optimisticMessages = $state<Message[]>([])
// Pagination state for older messages
let olderMessages = $state<Message[]>([])
let cursor = $state<number | null>(null)
let hasMore = $state(true)
// Reset pagination when conversation changes
$effect(() => {
void conversationId
olderMessages = []
cursor = null
hasMore = true
})
const serverMessages = $derived(liveQuery.data ?? [])
// Update cursor from live data
$effect(() => {
if (serverMessages.length > 0) {
cursor = serverMessages[serverMessages.length - 1]._creationTime
}
})
// Three-layer merge: optimistic + live + older
const messages = $derived.by(() => {
if (optimisticMessages.length === 0 && olderMessages.length === 0) {
return serverMessages
}
// Remove optimistic messages that the server has confirmed (by nonce)
const serverNonces = new Set(
serverMessages.filter((m) => m.nonce).map((m) => m.nonce)
)
const pending = optimisticMessages.filter(
(om) => !serverNonces.has(om.nonce)
)
return [...pending, ...serverMessages, ...olderMessages]
})
async function loadMore() {
if (!hasMore || !cursor || !client) return
const result = await client.query(
api.messages.listByConversationPaginated,
{ conversation: conversationId, cursor, limit: 50 }
)
if (!result) return
olderMessages = [...olderMessages, ...result.items]
cursor = result.nextCursor
hasMore = result.hasMore
}
function sendMessage(content: string) {
const nonce = crypto.randomUUID()
const now = Date.now()
// Show optimistically
optimisticMessages = [
{ _id: nonce, _creationTime: now, conversation: conversationId, content, nonce },
...optimisticMessages
]
// Send to server (your mutation or API call)
// The server should store the nonce so the live query
// can reconcile and remove the optimistic entry
}
</script>
<VirtualList
items={messages}
mode="bottomToTop"
defaultEstimatedItemHeight={60}
onLoadMore={loadMore}
{hasMore}
>
{#snippet renderItem(message: Message)}
<div class="message">{message.content}</div>
{/snippet}
</VirtualList><script lang="ts">
import { useQuery, useConvexClient } from 'convex-svelte'
import { api } from '$lib/convex/api'
import VirtualList from '@humanspeak/svelte-virtual-list'
type Message = {
_id: string
_creationTime: number
conversation: string
content: string
nonce?: string
}
let { conversationId } = $props()
const liveQuery = useQuery(
api.messages.listByConversation,
{ conversation: conversationId, limit: 50 }
)
const client = useConvexClient()
// Optimistic messages shown before server confirms
let optimisticMessages = $state<Message[]>([])
// Pagination state for older messages
let olderMessages = $state<Message[]>([])
let cursor = $state<number | null>(null)
let hasMore = $state(true)
// Reset pagination when conversation changes
$effect(() => {
void conversationId
olderMessages = []
cursor = null
hasMore = true
})
const serverMessages = $derived(liveQuery.data ?? [])
// Update cursor from live data
$effect(() => {
if (serverMessages.length > 0) {
cursor = serverMessages[serverMessages.length - 1]._creationTime
}
})
// Three-layer merge: optimistic + live + older
const messages = $derived.by(() => {
if (optimisticMessages.length === 0 && olderMessages.length === 0) {
return serverMessages
}
// Remove optimistic messages that the server has confirmed (by nonce)
const serverNonces = new Set(
serverMessages.filter((m) => m.nonce).map((m) => m.nonce)
)
const pending = optimisticMessages.filter(
(om) => !serverNonces.has(om.nonce)
)
return [...pending, ...serverMessages, ...olderMessages]
})
async function loadMore() {
if (!hasMore || !cursor || !client) return
const result = await client.query(
api.messages.listByConversationPaginated,
{ conversation: conversationId, cursor, limit: 50 }
)
if (!result) return
olderMessages = [...olderMessages, ...result.items]
cursor = result.nextCursor
hasMore = result.hasMore
}
function sendMessage(content: string) {
const nonce = crypto.randomUUID()
const now = Date.now()
// Show optimistically
optimisticMessages = [
{ _id: nonce, _creationTime: now, conversation: conversationId, content, nonce },
...optimisticMessages
]
// Send to server (your mutation or API call)
// The server should store the nonce so the live query
// can reconcile and remove the optimistic entry
}
</script>
<VirtualList
items={messages}
mode="bottomToTop"
defaultEstimatedItemHeight={60}
onLoadMore={loadMore}
{hasMore}
>
{#snippet renderItem(message: Message)}
<div class="message">{message.content}</div>
{/snippet}
</VirtualList>Resetting pagination on context change
When the user switches conversations (or any scoped context), the pagination state must be reset. Otherwise, older messages from the previous conversation would remain visible:
// Reset pagination when conversation changes
$effect(() => {
void conversationId // track the reactive dependency
olderMessages = []
cursor = null
hasMore = true
})// Reset pagination when conversation changes
$effect(() => {
void conversationId // track the reactive dependency
olderMessages = []
cursor = null
hasMore = true
})This pattern applies to any context where the underlying data set changes — channels, rooms, threads, or filtered views.
Key Design Decisions
Why _creationTime for pagination?
| Benefit | Explanation |
|---|---|
| Built-in | Convex adds _creationTime to every document automatically |
| Auto-indexed | Efficient queries without explicit index definitions |
| Monotonic | Guaranteed unique and strictly ordered |
| Numeric | No string parsing issues |
Why dual approach (subscription + one-time queries)?
| Component | Method | Why |
|---|---|---|
| First page | useQuery | Real-time updates via WebSocket subscription |
| Older pages | client.query | One-time fetch, no subscription needed for historical data |
Why merge live + paginated data?
- Live data: Always shows the absolute latest items with real-time updates
- Paginated data: Stable historical data loaded on demand
- Combined: Seamless infinite list with real-time updates at the top
Troubleshooting
Live indicator not showing
- Ensure
setup()is called in+layout.svelte - Check that
PUBLIC_CONVEX_URLis set - Verify WebSocket connection in browser DevTools (Network > WS)
Pagination returning no results
- Verify cursor is set from the live data’s last item
- Check that filter uses
q.lt()for descending order - Ensure the query is deployed (
npx convex deploy)
Data not updating in real-time
- Confirm you’re using
useQuery(notclient.query) for live data - Check Convex dashboard for function errors
- Verify WebSocket connection is established
Related
- Why Cursor Pagination? (Blog) — Deep dive on cursor vs offset pagination with Convex
- Convex Documentation
- convex-svelte
- Bottom-to-Top Mode
- Infinite Scroll Guide
- Get Started