Handling dropouts¶
In multiplayer experiments, participants may close their browser or lose their connection. uproot provides tools to detect dropouts and handle them gracefully.
Automatic dropout detection¶
Use watch_for_dropout to monitor a player and trigger a callback when they go offline:
def new_player(player):
player.dropout = False
watch_for_dropout(player, handle_dropout)
async def handle_dropout(player):
player.dropout = True
move_to_end(player)
The watcher checks every few seconds whether the player’s browser is still connected. If the player has been offline for longer than the tolerance period (default: 30 seconds), the handler fires.
watch_for_dropout¶
| Parameter | Type | Default | Description |
|---|---|---|---|
player |
player | — | The player to watch |
handler |
async function | — | Called when dropout is detected |
tolerance |
float |
30.0 |
Seconds of inactivity before triggering |
The handler receives the player as its argument. It can be an async function.
Register the watcher in new_player so it starts monitoring as soon as the player joins.
Moving dropouts to the end¶
The most common response to a dropout is to move them to the end page:
move_to_end(player) advances the player past all remaining pages. If they return, they will see the end page instead of being stuck on a wait page that blocks other players.
Marking dropouts manually¶
From the admin interface, select players and use the Mark as dropout action.
This sets player._uproot_dropout = True and triggers the admin dropout flow.
Programmatically:
mark_dropout marks the player for the dropout watcher. Register a watcher if
you want a handler to run; the function alone does not move the player or call
the handler immediately.
Handling dropouts in group experiments¶
In multiplayer experiments, a dropout can block other group members at synchronization points. A single participant closing their browser, walking away, or losing connectivity can leave their partner staring at “waiting for other participants” indefinitely.
A robust solution tracks dropout at the group level and handles three distinct dropout vectors:
- Browser disconnect: The participant closes the tab or loses connectivity (
watch_for_dropout). - Page timeout: The participant sits on a decision page without submitting (
timeout_reached). - Sync timeout: One participant submits but their partner never arrives at the wait page (
timeoutonSynchronizingWait).
The group-level drop pattern¶
Use a boolean group.dropped flag as the single source of truth. When any dropout vector fires, mark the group and redirect all other members to a dedicated Dropped page:
class C:
TIMEOUT = 60 # seconds to make a decision
SYNC_TIMEOUT = 90 # seconds to wait at sync points
def drop_group(group, culprit):
group.dropped = True
group.dropped_by = culprit.name
for p in group.players:
if p.name != culprit.name:
with p as pp:
move_to_page(pp, Dropped)
drop_group does three things: marks the group, records who caused the drop, and moves every other player to the Dropped page. The with p as pp: context manager is required here because you are mutating a player object from outside that player’s own page method.
Guard against double-dropping
Multiple dropout vectors can fire for the same group (e.g., a browser disconnect triggers watch_for_dropout at the same moment a page timeout fires). Always check if not group.get("dropped") before calling drop_group.
Registering watchers in after_grouping¶
Register watch_for_dropout in after_grouping, not new_player, because the handler needs access to the group. When iterating group.players inside after_grouping, wrap each player in a context manager:
class GroupPlease(GroupCreatingWait):
group_size = 2
@classmethod
def after_grouping(page, group):
group.dropped = False
for player in group.players:
player.timed_out = False
with player:
watch_for_dropout(player, handle_dropout)
The with player: block is needed because after_grouping receives the group, and iterating group.players yields player objects that require the context manager for uproot to track their mutations properly.
The dropout handler¶
The handler fires when a participant disconnects. It marks the player as timed out, drops the group if it has not been dropped already, and moves the disconnected player to the end:
async def handle_dropout(player):
player.timed_out = True
group = player.group
if group is not None and not group.get("dropped"):
drop_group(group, player)
move_to_end(player)
Page timeouts on decision pages¶
Add a timeout to every decision page. When it expires, timeout_reached drops the group:
class Dilemma(Page):
fields = dict(
cooperate=RadioField(
label="Do you wish to cooperate?",
choices=[(True, "Yes"), (False, "No")],
),
)
@classmethod
def timeout(page, player):
return C.TIMEOUT
@classmethod
def timeout_reached(page, player):
player.timed_out = True
if not player.group.get("dropped"):
drop_group(player.group, player)
@classmethod
def before_once(page, player):
if player.group.get("dropped"):
move_to_page(player, Dropped)
The before_once guard at the bottom is equally important: If the group was already dropped (because the other player timed out or disconnected), this player should not see the decision page at all. Instead, they get redirected to Dropped immediately.
Tip
If you have many decision pages, you can avoid repeating these three methods on each one. See Reducing repetition with a mixin class at the end of this page.
Sync timeout¶
A SynchronizingWait page can also time out. This covers the case where one player submits their decision but the other never arrives:
class Sync(SynchronizingWait):
@classmethod
def timeout(page, player):
return C.SYNC_TIMEOUT
@classmethod
def timeout_reached(page, player):
if not player.group.get("dropped"):
player.group.dropped = True
player.group.dropped_by = "sync_timeout"
@classmethod
def all_here(page, group):
if group.get("dropped"):
return
for player in group.players:
# ... compute and assign payoffs ...
The guard in all_here prevents payoff calculation for dropped groups.
The Dropped page¶
Create a terminal page that tells participants what happened. Distinguish between the player who timed out and their partner:
{% extends "Base.html" %}
{% block title %}Time expired{% endblock title %}
{% block main %}
{% if player.timed_out %}
<p>You did not make a choice in time. Your pair has been removed from this round.</p>
{% else %}
<p>Your partner did not make a choice in time. Your pair has been removed from this round.</p>
{% endif %}
{% endblock main %}
In your Python code, the Dropped page moves the player to the end after they see the message:
Add Dropped to the end of page_order:
Guarding downstream pages¶
Every page after grouping should check the drop flag and redirect. Add a before_once guard to your Results page (and any other post-sync page). (If you have many such pages, see the mixin appendix.)
class Results(Page):
@classmethod
def before_once(page, player):
if player.group.get("dropped"):
move_to_page(player, Dropped)
Excluding dropped groups from data¶
If your experiment uses a digest function, skip dropped groups so they do not contaminate the summary:
def digest(session):
dropped_pairs = 0
for group in session.groups(app=__name__):
if group.get("dropped"):
dropped_pairs += 1
continue
# ... normal payoff analysis ...
return {
# ...
"dropped_pairs": dropped_pairs,
}
If your experiment uses a pipeline function, include timed_out and dropped so you can filter in downstream analysis:
def pipeline(session):
rows = []
for group in session.groups(app=__name__):
for player in group.players:
player_data = player.within(app=__name__)
rows.append({
# ... other fields ...
"timed_out": player_data.get("timed_out"),
"dropped": group.get("dropped"),
})
return rows
Showing dropout counts in the admin digest¶
If your experiment uses an AdminDigest.html template, display a warning when groups were dropped:
{% if dropped_pairs > 0 %}
<div class="alert alert-warning mb-3">
{{ dropped_pairs }} pair{{ "s" if dropped_pairs != 1 }} dropped due to timeout.
</div>
{% endif %}
Adjusting tolerance¶
Lower the tolerance for fast-paced experiments where delays are critical:
Increase it for experiments where participants may be reading long instructions:
Checking dropout status in templates¶
Summary¶
| Feature | Purpose |
|---|---|
watch_for_dropout(player, handler, tolerance=30.0) |
Monitor a player for disconnection |
move_to_end(player) |
Move player past all remaining pages |
move_to_page(player, PageClass) |
Redirect a player to a specific page |
mark_dropout(pid) |
Manually mark a player as dropout |
player._uproot_dropout |
Internal dropout flag |
| Admin: Mark as dropout | Manual dropout from admin interface |
group.dropped |
Group-level flag for tracking dropped groups |
with p as pp: / with player: |
Context manager for cross-player mutations |
group.get("dropped") |
Safe check that returns None if not set |
before_once guard |
Redirect dropped players away from normal pages |
Appendix: reducing repetition with a mixin class¶
Optional—for readers comfortable with Python classes
This section shows a convenience pattern for experiments with many decision pages. It is not required. If you only have one or two decision pages, adding the methods directly (as shown above) is simpler and perfectly fine.
In the group dropout pattern above, every decision page needs the same three methods: timeout, timeout_reached, and before_once. If your experiment has several decision pages, repeating those methods on each one gets tedious. Python lets you factor them out into a mixin class. This is a small class that bundles reusable methods and can be combined with Page (or any other page type) via multiple inheritance.
Defining the mixin¶
class DroppableMixin:
"""Adds timeout + dropout guard to any page."""
@classmethod
def timeout(page, player):
return C.TIMEOUT
@classmethod
def timeout_reached(page, player):
player.timed_out = True
if not player.group.get("dropped"):
drop_group(player.group, player)
@classmethod
def before_once(page, player):
if player.group.get("dropped"):
move_to_page(player, Dropped)
Using the mixin¶
Place DroppableMixin before Page in the class definition. Python checks classes left to right, so methods from DroppableMixin take precedence over defaults in Page:
class Dilemma(DroppableMixin, Page):
fields = dict(
cooperate=RadioField(
label="Do you wish to cooperate?",
choices=[(True, "Yes"), (False, "No")],
),
)
class Negotiation(DroppableMixin, Page):
fields = dict(
offer=IntegerField(label="Your offer", min=0, max=100),
)
class Results(DroppableMixin, Page):
@classmethod
def templatevars(page, player):
return dict(payoff=player.payoff)
Each page now inherits the timeout and dropout guard automatically. You still define fields, templatevars, and any other page-specific methods as usual; they sit alongside the inherited ones without conflict.
Overriding a single method¶
If one page needs a different timeout, override just that method. The other two (timeout_reached and before_once) are still inherited from the mixin:
class LongDecision(DroppableMixin, Page):
@classmethod
def timeout(page, player):
return 120 # more time for this page
Pages that only need the guard¶
Some pages (like Results) do not need a timeout—they have no form to submit. The mixin still works: timeout returning a value on a page without fields is harmless. But if you prefer, you can skip the mixin and add just the guard directly:
class Results(Page):
@classmethod
def before_once(page, player):
if player.group.get("dropped"):
move_to_page(player, Dropped)
Either approach is fine. The mixin is most valuable when you have many decision pages that all share the same timeout and dropout logic.