Real-time unread message counter and read receipt tracker for Convex .
Tracks read positions per user per channel, supports bulk subscriptions via groups, and provides accurate unread counts. Built as a Convex Component .
Read position tracking — watermark-based with explicit unread/read range overrides
Message accounting — register/unregister messages for accurate unread counts
Unread counts — single channel, total across subscriptions, or batch fetch
Subscription management — subscribe/unsubscribe/mute senders/archive channels
Group operations — bulk subscribe/unsubscribe entire teams
React hooks — optimistic mark-as-read with localStorage offline support
Cleanup utility — cron-friendly mutation for garbage collection
npm install convex-unread-tracking
1. Register the component
// convex/convex.config.ts
import { defineApp } from "convex/server" ;
import unreadTracking from "convex-unread-tracking/convex.config" ;
const app = defineApp ( ) ;
app . use ( unreadTracking ) ;
export default app ;
2. Create your backend functions
// convex/unreads.ts
import { UnreadTracking } from "convex-unread-tracking" ;
import { components } from "./_generated/api.js" ;
import { mutation , query } from "./_generated/server.js" ;
import { v } from "convex/values" ;
const unreads = new UnreadTracking ( components . unreadTracking ) ;
export const sendMessage = mutation ( {
args : { channelId : v . string ( ) , userId : v . string ( ) , text : v . string ( ) } ,
handler : async ( ctx , args ) => {
const now = Date . now ( ) ;
const msgId = await ctx . db . insert ( "messages" , { ...args , timestamp : now } ) ;
// Register with unread tracking (same transaction)
await unreads . insertMessage ( ctx , {
channelId : args . channelId ,
timestamp : now ,
authorId : args . userId ,
} ) ;
return msgId ;
} ,
} ) ;
export const markRead = mutation ( {
args : { userId : v . string ( ) , channelId : v . string ( ) , timestamp : v . number ( ) } ,
handler : async ( ctx , args ) => {
await unreads . markReadUpTo ( ctx , args ) ;
} ,
} ) ;
export const getUnreadCount = query ( {
args : { userId : v . string ( ) , channelId : v . string ( ) } ,
handler : async ( ctx , args ) => {
return await unreads . getUnreadCount ( ctx , args ) ;
} ,
} ) ;
3. Subscribe users to channels
export const subscribe = mutation ( {
args : { userId : v . string ( ) , channelId : v . string ( ) } ,
handler : async ( ctx , args ) => {
await unreads . subscribe ( ctx , args ) ;
} ,
} ) ;
Method
Description
markReadUpTo(ctx, { userId, channelId, timestamp })
Mark everything up to timestamp as read
markReadRange(ctx, { userId, channelId, start, end })
Mark a specific range as read
markOneUnread(ctx, { userId, channelId, timestamp })
Mark a single item as unread
getLastRead(ctx, { userId, channelId })
Get read position for scroll anchor
getLastReads(ctx, { userId, channelIds })
Batch fetch read positions
Method
Description
insertMessage(ctx, { channelId, timestamp?, authorId? })
Register a message for unread counting
deleteMessage(ctx, { channelId, timestamp })
Remove a message from tracking
Method
Description
getUnreadCount(ctx, { userId, channelId })
Count for a single channel
getTotalUnreadCount(ctx, { userId })
Aggregate across all subscriptions
getSingleUnreads(ctx, { userId, channelIds })
Batch fetch per-channel counts
Method
Description
subscribe(ctx, { userId, channelId })
Start tracking unreads
unsubscribe(ctx, { userId, channelId })
Stop tracking (mute)
muteSender(ctx, { userId, targetUserId })
Filter out a sender
unmuteSender(ctx, { userId, targetUserId })
Remove sender filter
archive(ctx, { channelId })
Stop all tracking for a channel
Method
Description
addToGroup(ctx, { groupId, userId })
Add user to a group
removeFromGroup(ctx, { groupId, userId })
Remove from group
subscribeAll(ctx, { groupId, channelId })
Subscribe entire group
unsubscribeAll(ctx, { groupId, channelId })
Unsubscribe entire group
// convex/crons.ts
import { cronJobs } from "convex/server" ;
const crons = cronJobs ( ) ;
crons . daily ( "cleanup unreads" , { hourUTC : 3 , minuteUTC : 0 } ,
internal . unreads . cleanup ) ;
export default crons ;
import { useMarkAsRead , useOptimisticUnreadCount } from "convex-unread-tracking/react" ;
function Channel ( { channelId } ) {
const { markRead, getPendingTimestamp, confirmRead } = useMarkAsRead ( ) ;
// Optimistic count — shows 0 immediately after marking read
const serverCount = useQuery ( api . unreads . getUnreadCount , { userId, channelId } ) ;
const count = useOptimisticUnreadCount ( serverCount , getPendingTimestamp ( channelId ) !== null ) ;
const handleMarkRead = async ( ) => {
markRead ( channelId , Date . now ( ) ) ; // optimistic
await markReadMutation ( { userId, channelId, timestamp : Date . now ( ) } ) ;
confirmRead ( channelId ) ; // confirm sync
} ;
}
Live Demo
MIT