Why does my message stream stop on mobile?
why does my message stream stop when on mobile?
Based on what I've found, I have a good understanding. Let me provide you with an answer based on the code I've reviewed:
##Why Your Message Stream Stops on Mobile
The issue likely stems from how mobile browsers handle background tabs and HTTP streaming connections. Here's what's happening:
###The Root Cause
Your app uses a streaming architecture with these components:
- API Route (
/app/api/stream/route.ts) - Serves streaming responses usingReadableStream - WorkflowChatTransport (
/lib/workflow-ai/workflow-chat-transport.ts) - A custom transport layer from Vercel's Workflow that handles the streaming connection - StreamingContent Component - Displays streaming messages in real-time
On mobile, there are two main issues:
####1. Browser Background Suspension
When you switch tabs or the browser goes to background on mobile:
- Mobile browsers pause fetch/ReadableStream operations to conserve battery and bandwidth
- The streaming connection is suspended and may not resume properly
- This affects both iOS Safari and Android Chrome
####2. Connection Timeout
Long-lived streaming connections have inherent risks:
- Network state changes (WiFi ↔ cellular) can break the connection
- Mobile networks are more volatile
- The stream reader may not properly detect disconnections
###What The Code Does
Looking at streaming-content.tsx, the app:
const { messages, status, resumeStream } = useChat<AgentUIMessage>({
id: commentId,
transport,
})
useEffect(() => {
resumeStream() // Only tries to resume once on mount
}, [resumeStream])
The issue: resumeStream() is only called once on component mount, and there's no automatic reconnection logic when the tab comes back to focus or after a network interruption.
###Solutions
To fix this, you should:
- Add visibility change listener - Reconnect when the tab regains focus
- Implement exponential backoff retry - Automatically retry with delays
- Monitor connection state - Detect when stream breaks and attempt recovery
- Set reasonable timeouts - Use shorter-lived requests or split long operations
Would you like me to examine the actual forum post or issue about this, or help design a fix?
good diagnosis. also, what if the stream stopped when i go back to the tab? say i add resumeStream() on winodow re-focus. but the stream now 404s. what now? router.refetch()?
Based on my investigation, here's what I've learned about why the stream 404s:
##The 404 Scenario & Why router.refresh() Won't Work
Looking at /app/api/stream/route.ts, the stream returns 404 when:
if (!(comment?.runId && comment?.streamId)) {
return null // → 404
}
The problem: When a streaming LLM comment finishes, the backend clears the streamId from the database (based on the schema, streamId is nullable). So when you try to resumeStream() after the tab refocuses and the stream has already completed, the endpoint returns 404 because there's no active stream anymore.
Why router.refresh() won't work:
router.refresh()re-fetches the page SSR data, which would re-query comments from the database- At that point, the comment has
streamId: nullandcontent: [...](the final completed message) - The component would try to show the completed message instead of reconnecting to a stream that no longer exists
##The Better Solution
Instead of just resumeStream() on refocus, you need to detect if the stream already finished:
// In streaming-content.tsx
useEffect(() => {
// Listen for visibility/focus changes
const handleFocus = () => {
// Check if messages exist and stream isn't already active
if (messages.length > 0 && status !== "streaming") {
// Stream already completed - don't try to reconnect
return
}
// Stream was interrupted - try to resume
resumeStream()
}
window.addEventListener("focus", handleFocus)
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
handleFocus()
}
})
return () => {
window.removeEventListener("focus", handleFocus)
document.removeEventListener("visibilitychange", handleFocus)
}
}, [messages, status, resumeStream])
The key insight from the code:
messages(populated from the stream) indicates completionstatusfromuseChattells you the stream state- If there are messages and status isn't "streaming", the stream already finished and there's nothing to reconnect to
- If there are no messages and it was streaming, then yes, reconnect
On 404: The transport should handle this gracefully - it either needs to:
- Give up (stream already finished, show what we have)
- Retry with backoff (temporary network issue)
- Fall back to
router.refresh()only if the entire comment disappeared (true error case)
If there are messages and status isn't "streaming", the stream already finished and there's nothing to reconnect to
sure chief, but in this case i have a comment that was cropped mid stream, and i can't reconnect to the stream eithrr
Now I understand the issue completely:
##The Real Problem: Orphaned Streams
When you go back to the tab and the stream has been cropped mid-stream:
- The workflow agent is still running - it's in the middle of streaming and will eventually call
closeStreamStep - The
streamIdis still in the DB - so the endpoint doesn't return 404 - But the stream handle is dead - because
getRun(comment.runId).getReadable({ namespace: comment.streamId })returns a stream that's no longer connected to the original write end - The timeout/abort happened on the client - so the client disconnected from the
ReadableStream
When you call resumeStream(), it tries to reconnect to that same runId and streamId, but the new getReadable() call creates a new stream reader that:
- Might be stale (the original write end was already written to)
- Or gets nothing because the workflow engine doesn't replay what was already streamed
The solution isn't router.refresh() - it's a manual retry/rerun:
Since the workflow is orphaned mid-stream, you need to:
- Detect that reconnection failed after N attempts
- Offer the user a "Retry" button that calls
rerunLlmComment()(which already exists in your code) - This creates a new comment with a new
streamIdandrunId, starting fresh
Looking at your code, this is exactly what the retry button does:
// From streaming-content.tsx
function handleRetry() {
startTransition(async () => {
await rerunLlmComment({ commentId })
router.refresh()
})
}
So the real fix is: Add auto-retry logic with exponential backoff that, if reconnection fails, automatically offers/triggers the retry. Or make retry automatic after a threshold.
Does that match what you're seeing?
what? you're not making any sense chief
END OF POST