Chatbot Conversation State Recovery#
Dify's web chatbot persists the active conversation_id across page reloads using a dual-storage architecture. When server-side state diverges from stored state (e.g., a conversation is deleted, or a periodic cleanup job runs), the client can get stuck in error loops. This article covers the storage model, the failure modes it's subject to, and how each is resolved.
Storage Architecture#
The central hook is useConversationSelection in web/app/components/base/chat/storage.ts. It manages two stores, both using a nested scopeId β userId β conversationId structure :
| Store | Backend | Key | Scope |
|---|---|---|---|
| Tab-scoped | sessionStorage | TAB_CONVERSATION_ID_INFO | Current browser tab lifetime |
| Cross-tab fallback | localStorage | CONVERSATION_ID_INFO | All tabs, all sessions |
Storage keys are defined in constants.ts. Both stores are powered by the foxact library's createSessionStorageState / createLocalStorageState .
ID resolution priority :
URL conversationId > tab sessionStorage > cross-tab localStorage > '' (new conversation)
On first load, a tab seeds its sessionStorage from localStorage, then becomes independent β changes in other tabs don't switch the current tab's active conversation . The hook is consumed by two parallel implementations:
- Embedded chatbot:
web/app/components/base/chat/embedded-chatbot/hooks.tsxβ passes URL-extractedconversationIdinto the hook - Chat-with-history:
web/app/components/base/chat/chat-with-history/hooks.tsxβ relies solely on storage (no URL param passed)
Failure Modes and Fixes#
1. Stale Closure: Conversation Reset Retains Old ID#
Symptom: Starting a new conversation keeps the old conversation_id after a reset.
Root cause: handleConversationIdInfoChange originally spread conversationIdInfo from the closure. Under React state batching, it captured a stale snapshot β the reset wrote the old ID back.
Fix (PR #33375): Both hooks switched to functional state updates (setTabConversationIdInfo(prev => ...) / setLastConversationIdInfo(prev => ...)) so the update always reads the latest state . conversationIdInfo was also removed from the useCallback dependency array to eliminate the stale capture.
2. Stale Storage After Server-Side Deletion (404 Loop)#
Symptom: After a conversation is deleted server-side (or cleaned up by a periodic task), the persisted conversation_id causes a 404 on next load, triggering an infinite retry loop with noisy error toasts.
useShareChatListinweb/service/use-share.tscatches 404 responses and throws a typedEnvironmentConversationNotFoundErrorfor environment-hosted apps (those whereaddress.kind === 'environment').- A
useEffectinchat-with-history/hooks.tsxcatches this error, callshandleConversationIdInfoChange(''), and resets the active conversation to "New Chat" . - The embedded chatbot hook exposes
removeConversationIdInfofromuseConversationSelection;removeConversationIdInforemoves the stale entry from both stores and writes an explicit empty entry tosessionStorageto prevent fallback to a value written by another tab .
3. URL conversation_id Overridden by Stored State (Embedded Chatbot)#
Symptom: Embedding the chatbot with an explicit ?conversation_id=... URL parameter silently uses the cached localStorage value instead.
Root cause: The resolution memo evaluated conversationIdInfo[...] || conversationId, so any cached value masked the URL parameter.
Fix (PR #35519): Inverted to conversationId || stored, now enforced centrally in useConversationSelection . In the embedded chatbot, conversationId (extracted from URL params at mount) is passed as the conversationId option , and allowResetChat is set to false when a URL conversation_id is present .
4. Invalid conversation_id Hangs Streaming Endpoints (Backend)#
Symptom: Sending a message with a stale/invalid conversation_id causes the server to hang rather than returning a prompt 404.
Root cause: Validation happened inside the streaming generator, so the error was thrown mid-stream instead of before the response started.
Fix (PR #38801): Added eager pre-validation (ConversationService.get_conversation()) before AppGenerateService.generate() is called in both api/controllers/console/explore/completion.py and api/controllers/service_api/app/completion.py. An invalid ID now returns a clean 404 immediately, which the frontend 404-recovery logic can handle.
Key Files#
| File | Role |
|---|---|
web/app/components/base/chat/storage.ts | useConversationSelection β dual-storage hook; ID resolution, write, and removal |
web/app/components/base/chat/constants.ts | CONVERSATION_ID_INFO (localStorage key) and TAB_CONVERSATION_ID_INFO (sessionStorage key) |
web/app/components/base/chat/embedded-chatbot/hooks.tsx | Embedded chatbot: URL param extraction, useConversationSelection wiring, removeConversationIdInfo |
web/app/components/base/chat/chat-with-history/hooks.tsx | Chat-with-history: 404 useEffect recovery, handleConversationIdInfoChange('') reset |
web/service/use-share.ts | useShareChatList β 404 detection, EnvironmentConversationNotFoundError |
Related PRs#
| PR | Fix |
|---|---|
| #33375 | Stale closure in handleConversationIdInfoChange β functional state update |
| #34945 | Clear stale conversation_id from storage on 404 |
| #35519 | Prioritize URL conversation_id over stored state in embedded chatbot |
| #38801 | Eager conversation_id validation on service-api and explore endpoints |
| #39593 | Follow-up 404 loop fix (dual-storage architecture) |
| #40709 | Cross-tab isolation via dual-storage (sessionStorage + localStorage) |