Thread Resume and Reconnect Lifecycle#
Chainlit's WebSocket resume flow is orchestrated by three hooks — on_chat_start, on_chat_resume, and the internal resume_thread helper — all triggered from the connection_successful handler in backend/chainlit/socket.py. Understanding the decision tree and its known failure modes is essential for debugging duplicate-state and missing-callback issues.
Connection Flow Decision Tree#
When a Socket.IO connect event arrives, Chainlit first tries to restore the existing WebsocketSession by matching the client-supplied sessionId. Restoring a session calls WebsocketSession.restore(), which rebinds the socket ID and sets session.restored = True — it does not reset any other session state.
After the transport is ready, the client emits connection_successful. The handler's decision tree :
- Restored session, no first interaction → run
on_chat_start(if not already started) and return early. thread_id_to_resumeis set andon_chat_resumeis registered → callresume_thread(), emitfirst_interaction, call the user'son_chat_resumehook, populatechat_contextfrom persisted steps, then emitresume_threadto the frontend.- Fresh session → run
on_chat_start.
resume_thread() loads the thread from the data layer, copies its metadata dict wholesale into user_sessions[session.id], and restores chat_profile and chat_settings from that metadata .
Session State and Persistence#
Two sources of session state interact during a resume:
user_sessions(global dict, keyed bysession.id) — holds arbitrary app data set viacl.user_session.set(). On disconnect,to_persistable()merges this dict withchat_profile,chat_settings, andclient_typeand writes the result to thread metadata viapersist_user_session().- Thread metadata — read back by
resume_thread()on reconnect and used to overwriteuser_sessions[session.id]and session fields.
to_persistable() respects config.project.persist_user_env: when False (the default), the env key is replaced with an empty dict before the write .
chat_context is a separate in-memory list keyed by session.id. On resume, persisted message steps are reconstructed via Message.from_dict and appended with chat_context.add(). Deduplication uses object identity (message not in list), so new objects created from the same dict are not deduplicated .
Known Bugs and Regression History#
on_chat_start duplicate dispatch (fixed in PR #2891)#
Originally, every restored session returned early unconditionally — keeping on_chat_start from firing at all when a session was restored before the user had interacted (issue #2535). PR #2549 narrowed the guard to restored and not has_first_interaction, but left two independent schedule sites in connection_successful, each capable of dispatching on_chat_start. This caused the callback to fire twice on Socket.IO auto-reconnect, React 18 StrictMode double-mount, or reverse-proxy retry .
PR #2891 added chat_started: bool = False to BaseSession and guards both schedule sites with not context.session.chat_started, flipping the flag synchronously before asyncio.create_task. The flag is never reset within a session lifetime, and restore() intentionally leaves it untouched .
on_chat_resume re-runs on every reconnect (open as of 2026-09-27, issue #3061)#
The idempotency fix in PR #2891 only covers on_chat_start. The resume branch has no equivalent guard. For any session where thread_id_to_resume is set, every reconnect will:
- Re-call
resume_thread(), overwritinguser_sessions[session.id]with stale persisted metadata . - Re-run the
on_chat_resumehook. - Re-append all thread steps to
chat_context, producing duplicate conversation history becauseChatContext.adduses identity comparison .
The regression was introduced by PR #2549's narrowed early-return: before 2.8.2, every restored session returned immediately. The proposed fix in issue #3061 is a one-line extension of the guard:
# proposed fix (not yet merged)
if context.session.restored:
if (
not context.session.has_first_interaction
and config.code.on_chat_start
and not context.session.chat_started
):
context.session.chat_started = True
task = asyncio.create_task(config.code.on_chat_start())
context.session.current_task = task
return # <-- always return for restored sessions
resume_thread ordering and on_chat_resume message overwrite (issue #2611, PR #2612)#
PR #2612 (closed, not merged) flagged an ordering issue: resume_thread(thread) was called after on_chat_resume, so any cl.Message.send() calls inside on_chat_resume would be overwritten when the frontend rebuilt the message list from the persisted thread. The PR proposed calling resume_thread before invoking the hook.
Key Source Files#
| File | Role |
|---|---|
backend/chainlit/socket.py | connect, connection_successful, resume_thread, persist_user_session, disconnect |
backend/chainlit/session.py | WebsocketSession, BaseSession, restore(), to_persistable(), chat_started flag |
backend/chainlit/chat_context.py | ChatContext.add() — identity-based deduplication used during thread resume |
Debugging Checklist#
on_chat_startfires twice: check ifsession.chat_startedisTruebefore the second dispatch; verify PR #2891 is in the installed version (≥2.x).on_chat_resumefires twice / duplicate chat history: open bug (#3061); workaround is to checkcl.user_session.get("resume_ran")inside the hook and return early.- App state reset on reconnect:
resume_thread()overwritesuser_sessions[session.id]from DB; do not rely on in-memory user session data set before the reconnect unless it was also persisted. - Messages from
on_chat_resumeoverwritten: investigatecontext.emitter.resume_thread(thread)call order relative to the hook (see PR #2612).