---
title: "Why your agent leaves on browser refresh (and when to keep it)"
date: "2026-06-17"
authors: "Chris Wilson"
tags: "guide"
---

# Why your agent leaves on browser refresh (and when to keep it)

You build a voice agent, connect from the browser, and have a great conversation. Then the user hits **refresh**, and the agent is gone. Reconnect, and it has no memory of what you just said. In your agent logs you see:

```
livekit.agents - closing agent session due to participant disconnect
```

This is working as designed, but the default is tuned for telephony, not for long-lived web and mobile sessions. This guide explains what's actually happening, how to keep the agent in the room across a reconnect, and (because this is the part people skip) when you should *not* change the default.

## Background: two separate things end a session

When a browser refreshes, the WebRTC connection drops with the disconnect reason `CLIENT_INITIATED`. Two independent mechanisms react to that, and to keep an agent alive you have to understand both.

### 1. The agent SDK closes the session (the immediate cause)

`AgentSession` talks to a single *linked participant*, which by default is the first non-agent participant to join. [`RoomIO`](https://docs.livekit.io/agents/logic/sessions/#roomio) watches that participant, and when they disconnect it closes the session:

```python
# livekit-agents/livekit/agents/voice/room_io/room_io.py
def _on_participant_disconnected(self, participant: rtc.RemoteParticipant) -> None:
    if not (linked := self.linked_participant) or participant.identity != linked.identity:
        return
    self._participant_available_fut = asyncio.Future[rtc.RemoteParticipant]()

    if (
        self._options.close_on_disconnect
        and participant.disconnect_reason in DEFAULT_CLOSE_ON_DISCONNECT_REASONS
        and not self._close_session_atask
        and not self._delete_room_task
    ):
        logger.info("closing agent session due to participant disconnect ...")
        self._agent_session._close_soon(reason=CloseReason.PARTICIPANT_DISCONNECTED)
```

`close_on_disconnect` defaults to `True`, and the reasons that trigger it are:

```python
# livekit-agents/livekit/agents/voice/room_io/types.py
DEFAULT_CLOSE_ON_DISCONNECT_REASONS: list[rtc.DisconnectReason.ValueType] = [
    rtc.DisconnectReason.CLIENT_INITIATED,
    rtc.DisconnectReason.ROOM_DELETED,
    rtc.DisconnectReason.USER_REJECTED,
]
```

A refresh is `CLIENT_INITIATED`, so the session closes *immediately*, before any room timeout matters. That's the log line above, and it's why the reconnected page meets a fresh, amnesiac agent. This option is documented under [Agent session → Clean up options](https://docs.livekit.io/agents/logic/sessions/#clean-up-options).

### 2. The server closes the room when the last human leaves

Separately, LiveKit Cloud manages the room's lifecycle. The rule is specific: a room *"is automatically closed when the last **non-agent** participant leaves"* ([Server lifecycle](https://docs.livekit.io/agents/server/lifecycle/)). Agents do **not** keep a room alive. Once the last human leaves, the room closes after a short grace period, the [`departureTimeout`](https://docs.livekit.io/reference/other/roomservice-api/#createroom) (default 20 seconds), and *"any remaining agents disconnect."*

This is the subtlety that trips people up: `emptyTimeout` and `departureTimeout` are real, but they don't override the agent-side `close_on_disconnect`, and they key off humans, not agents. So even if you stop mechanism #1, the room itself is on a countdown the moment your user disconnects.

<Alert variant="important" title="The mental model">
  A browser refresh is a disconnect followed by a reconnect a second or two later. To survive it you must (a) stop the agent SDK from closing the session on the disconnect, **and** (b) keep the room open long enough for the user to come back. Both, or it still dies.
</Alert>

The whole decision, from a disconnect to what happens to the agent, fits in one picture:

<Mermaid
  chart={`flowchart TD
D["Participant<br/>disconnects"] --> K{"Participant kind?"}
K -->|"SIP / phone"| H["Hangup is final.<br/>Let it close."]
K -->|"STANDARD / web"| R{"Expect refreshes<br/>or brief drops?"}
R -->|"No"| C["Close now<br/>(the default)"]
R -->|"Yes"| F["Keep the agent:<br/>turn off auto-close,<br/>set a departureTimeout"]
F --> T{"User back before<br/>departureTimeout?"}
T -->|"Yes"| S["Session continues<br/>memory intact"]
T -->|"No"| X["Room closes,<br/>agent cleaned up"]
`}
/>

## Keeping the agent alive across a reconnect

### Step 1: turn off `close_on_disconnect`

Pass it through `RoomOptions` (Python) / `RoomInputOptions` (Node.js) when you start the session:

```python
from livekit.agents import room_io

await session.start(
    agent=my_agent,
    room=ctx.room,
    room_options=room_io.RoomOptions(close_on_disconnect=False),
)
```

```ts
// Node.js
await session.start({
  agent: myAgent,
  room: ctx.room,
  inputOptions: { closeOnDisconnect: false },
});
```

Now the disconnect no longer tears down the session. The agent keeps its conversation history, so when the user returns it still remembers what they said.

### Step 2: give the room a `departureTimeout` that covers the gap

Because the server starts the room-close countdown when the last human leaves, set a [`departureTimeout`](https://docs.livekit.io/reference/other/roomservice-api/#roomconfiguration) long enough to outlast a reconnect. A refresh reconnects in a second or two, but for an interview agent where a candidate might lose Wi-Fi or step away, you may want minutes. Configure it in the room configuration you attach to the token (or via `CreateRoom`):

```python
from livekit.api import RoomConfiguration

# In the token used by the browser client
room_config = RoomConfiguration(
    departure_timeout=300,  # keep the room open 5 min after the last human leaves
    empty_timeout=300,      # and 5 min if no one ever joins
)
```

### Step 3: reconnect with the *same* identity

When the user reconnects, they must use the **same participant identity**. `RoomIO` re-resolves its linked participant when someone rejoins, so a matching identity re-links the agent to the same person automatically. A new random identity each load will look like a different user.

The result: refresh the page, reconnect, and the agent is still there, mid-conversation.

## In-session memory vs. remembering across sessions

Keeping the session alive solves *in-session* memory. The agent holds the conversation in its `ChatContext` for as long as the `AgentSession` lives, so a refresh that reconnects in time finds everything intact. But that memory is still tied to one session. Once the room finally closes (the user is gone past `departureTimeout`, or closes the tab for good), the session ends and that context is gone. The next time the same person connects, they get a brand-new session that starts from zero.

If you want the agent to greet a returning user by name or recall what they said last week, you need *persistent* memory: write the relevant facts to a database keyed by a stable user id, then load them back at the start of the next session. LiveKit gives you the two hooks this needs. Identify the user from [job metadata](https://docs.livekit.io/agents/server/job/) before `ctx.connect()` and pre-load their profile into the `ChatContext`, then persist a session report on hangup with the [`on_session_end`](https://docs.livekit.io/testing/observability/data/) callback.

For a complete, working example, see [MongoDB Vector Search for Voice Agents](/blog/mongodb-voice-agent-memory), which wires Atlas into a LiveKit agent for per-user memory and includes a clonable starter kit. A [demo video](https://www.youtube.com/watch?v=t-NJWI2QL_E) shows it in action.

<Alert variant="note" title="Two layers, two jobs">
  `close_on_disconnect=False` handles "the user will be right back." Persistent memory handles "the user will be back next week." They're complementary: keep the session alive across a blip, and write durable facts to a database so a fresh session can pick up where the last one ended.
</Alert>

## Don't strand the agent: how it should leave

Turning off `close_on_disconnect` creates a new risk: an agent that *never* leaves, holding a worker process and live STT/LLM/TTS connections (and the bill that comes with them). Pair it with a cleanup path. You have three options, and they compose:

**Let the room timeout do it (simplest).** With `close_on_disconnect=False` and a `departureTimeout` of, say, 300s, the agent survives short blips but the server still closes the room (and disconnects the agent) once the user is gone past the timeout. Cleanup is automatic and bounded.

**Detect an absent user in the agent (most control).** Use [`user_away_timeout`](https://docs.livekit.io/agents/logic/sessions/#handling-inactive-users) with the [`user_state_changed`](https://docs.livekit.io/reference/agents/events/#user_state_changed) event to notice silence, prompt a few times, then shut down:

```python
import asyncio
from livekit.agents import AgentSession, UserStateChangedEvent

session = AgentSession(user_away_timeout=15.0)  # seconds of silence → "away"

inactivity_task: asyncio.Task | None = None

async def check_if_user_present():
    for _ in range(3):
        await session.generate_reply(
            instructions="The user has been quiet. Politely check if they're still there."
        )
        await asyncio.sleep(10)
    session.shutdown()  # give up and end the session

@session.on("user_state_changed")
def _on_user_state_changed(ev: UserStateChangedEvent):
    global inactivity_task
    if ev.new_state == "away":
        inactivity_task = asyncio.create_task(check_if_user_present())
    elif inactivity_task:
        inactivity_task.cancel()
        inactivity_task = None
```

**Delete the room immediately on close.** If you want the room gone the instant the session ends rather than lingering for the grace period, set [`delete_room_on_close=True`](https://docs.livekit.io/agents/logic/sessions/#clean-up-options).

<Alert variant="caution" title="Crash is not a graceful close">
  If an agent disconnects unexpectedly (OOM, crash), LiveKit detects it within about 15s and [dispatches a fresh agent](https://docs.livekit.io/agents/server/lifecycle/) to the room. That re-dispatched agent is a brand-new session with no memory. Surviving a refresh relies on keeping the *same* session alive with the settings above, not on re-dispatch.
</Alert>

## Telephony vs. web: branch on the participant kind

`close_on_disconnect` is a single session-level setting (a plain `bool` on `RoomOptions`), not something you can scope per participant. It applies to whichever participant the session is linked to, whether that's a [`STANDARD`](https://docs.livekit.io/intro/basics/rooms-participants-tracks/participants/#types-of-participants) web user or a [`SIP`](https://docs.livekit.io/telephony/) caller. That matters because the same disconnect reason means opposite things for the two kinds:

- **`STANDARD` (browser, mobile):** a `CLIENT_INITIATED` disconnect is often just a refresh or a brief network blip. You usually want to stay.
- **`SIP` (telephony):** a clean hangup (a SIP `BYE` from either side) *also* surfaces as `CLIENT_INITIATED` ([Handling mid-call disconnections](https://docs.livekit.io/telephony/making-calls/outbound-calls/#mid-call-disconnections)). But a hangup is final: there is no "reconnect," and you want to end the session and free the worker right away.

So you can't tell intent from the disconnect reason alone. You have to look at the participant kind.

If your agent only ever serves one kind, pick the matching default: web and mobile get `close_on_disconnect=False` plus a cleanup path, while pure telephony keeps the default `close_on_disconnect=True` so a hangup ends the call immediately.

If a single agent serves both (a common setup, since the same entrypoint can take inbound calls and web sessions), keep `close_on_disconnect=False` for the web case and add a `participant_disconnected` handler that shuts down when the caller is a SIP participant:

```python
from livekit import rtc

# With close_on_disconnect=False, the agent survives web refreshes.
# A phone hangup, though, is final: end the session right away.
@ctx.room.on("participant_disconnected")
def on_participant_disconnected(participant: rtc.RemoteParticipant):
    # A clean SIP hangup (BYE) arrives as CLIENT_INITIATED, the same reason a
    # browser refresh uses, so branch on the participant kind, not the reason.
    if participant.kind == rtc.ParticipantKind.PARTICIPANT_KIND_SIP:
        session.shutdown()
```

Identify SIP callers with `participant.kind == rtc.ParticipantKind.PARTICIPANT_KIND_SIP` (see [Identifying SIP callers](https://docs.livekit.io/telephony/accepting-calls/workflow-setup/#identifying-sip-callers)). For richer SIP-only behavior, inspect the [SIP attributes](https://docs.livekit.io/reference/telephony/sip-participant/) such as `sip.phoneNumber`.

## When to keep `close_on_disconnect=False`, and when not to

This is the decision that matters. Setting it the wrong way either frustrates users or quietly burns money.

### Keep the agent alive (`close_on_disconnect=False`) when:

- **Browser and mobile sessions** where a refresh, tab switch, or network blip is routine and expected.
- **Long or high-stakes sessions** such as interviews, tutoring, or support, where a momentary drop shouldn't throw away the conversation.
- **Multi-party rooms** where one participant leaving shouldn't end the agent for everyone else.

In all of these, **always** pair it with a cleanup path (a sane `departureTimeout`, an away-timeout shutdown, or both).

### Leave the default (`close_on_disconnect=True`) when:

- **Telephony / SIP.** A hangup *is* the end of the call. You want the session to close immediately and free the worker. This is exactly why the default exists; see [Handling mid-call disconnections](https://docs.livekit.io/telephony/making-calls/outbound-calls/#mid-call-disconnections), where `CLIENT_INITIATED`, `ROOM_DELETED`, and `USER_REJECTED` are treated as call-ending. If one agent handles both telephony and web, see the section above on branching by participant kind.
- **Single-shot tasks** where a disconnect genuinely means "done."
- **Cost-sensitive workloads.** Every open session holds a worker process and live model connections. If you keep sessions open on disconnect *without* a reliable shutdown path, a flaky network can leave a pile of orphaned agents running indefinitely.

The short version: `close_on_disconnect=True` optimizes for *freeing resources fast*; `False` optimizes for *not losing the conversation*. Pick based on whether a disconnect in your app means "the user is done" or "the user will be right back." If you pick `False`, make sure something still ends the session when they're truly gone.

## Related resources

- [Agent session → Clean up options](https://docs.livekit.io/agents/logic/sessions/#clean-up-options): `close_on_disconnect` and `delete_room_on_close`
- [Handling inactive users](https://docs.livekit.io/agents/logic/sessions/#handling-inactive-users): `user_away_timeout` and the away/shutdown pattern
- [Server lifecycle](https://docs.livekit.io/agents/server/lifecycle/): when rooms close and agents disconnect
- [Room management](https://docs.livekit.io/intro/basics/rooms-participants-tracks/rooms/): room lifecycle and creation
- [Room service API reference](https://docs.livekit.io/reference/other/roomservice-api/#createroom): the `emptyTimeout` and `departureTimeout` fields (with defaults) on `CreateRoom` and `RoomConfiguration`
- [Events and error handling](https://docs.livekit.io/reference/agents/events/): `CloseReason`, `user_state_changed`, and session events
- [Types of participants](https://docs.livekit.io/intro/basics/rooms-participants-tracks/participants/#types-of-participants): the `STANDARD`, `SIP`, and `AGENT` participant kinds
- [Identifying SIP callers](https://docs.livekit.io/telephony/accepting-calls/workflow-setup/#identifying-sip-callers): branching on `participant.kind` for telephony
- [MongoDB Vector Search for Voice Agents](/blog/mongodb-voice-agent-memory): persistent per-user memory that survives across sessions
- [Handling mid-call disconnections (telephony)](https://docs.livekit.io/telephony/making-calls/outbound-calls/#mid-call-disconnections): why the default closes on `CLIENT_INITIATED`
- [`room_io.py`](https://github.com/livekit/agents/blob/main/livekit-agents/livekit/agents/voice/room_io/room_io.py) and [`types.py`](https://github.com/livekit/agents/blob/main/livekit-agents/livekit/agents/voice/room_io/types.py): the disconnect-handling source