Page timeouts¶
Timeouts automatically advance participants to the next page after a specified duration. Use them for timed tasks, real-effort experiments, or to keep participants moving through your study.
Static timeout¶
Set a fixed timeout in seconds as a class attribute:
When the timeout expires, the page submits automatically with whatever data has been entered.
Dynamic timeout¶
Use a method to calculate the timeout based on player state:
class AdaptiveTask(Page):
@classmethod
def timeout(page, player):
# Faster participants get less time
base_time = 120
bonus = player.correct_answers * 5
return base_time - bonus
The method receives page and player and returns the timeout in seconds. Return None to disable the timeout for that player.
Handling timeout expiration¶
The timeout_reached callback runs when the timeout expires:
class TimedQuiz(Page):
timeout = 30
@classmethod
def timeout_reached(page, player):
player.timed_out = True
player.score = 0 # Penalty for not answering in time
This callback runs before the page advances. Use it to:
- Record that the participant timed out
- Apply penalties or default values
- Set flags for conditional logic later
Timeouts and may_proceed¶
A page with a may_proceed method keeps participants on the page until a condition holds. A timeout overrides this: once the deadline has passed, the page advances even if may_proceed returns False. This keeps wait pages from hanging forever when time runs out.
uproot counts a timeout as reached up to one second before the deadline. In that last second, the page only advances if may_proceed allows it. So if the next page relies on the deadline having passed, check the deadline in may_proceed:
class Negotiate(Page):
@classmethod
def timeout(page, player):
from time import time
return max(0, player.group.deadline - time())
@classmethod
def may_proceed(page, player):
from time import time
return player.group.agreed or time() >= player.group.deadline
Without may_proceed, a participant can reach the next page up to one second before the deadline. To change this tolerance, set upd.TIMEOUT_TOLERANCE (in seconds) in main.py.
Timeout spanning multiple pages¶
For a shared timeout across several pages, store the deadline and calculate remaining time dynamically:
class InitializeTimeout(NoshowPage):
@classmethod
def after_always_once(page, player):
from time import time
player.deadline = time() + 60 # 60 seconds total
player.failed = False
class TimedPage(Page):
@classmethod
def timeout(page, player):
from time import time
return max(0, player.deadline - time())
@classmethod
def timeout_reached(page, player):
if not player.failed:
player.failed = True
class Task1(TimedPage):
pass
class Task2(TimedPage):
pass
class Task3(TimedPage):
pass
page_order = [
InitializeTimeout,
Task1,
Task2,
Task3,
Results,
]
All three task pages share the same 60-second deadline. If time runs out on any page, player.failed is set.
See the timeout_multipage example
Timeouts with live methods¶
Timeouts work well with live methods for real-effort tasks:
class Sumhunt(Page):
timeout = 120 # 2 minutes to solve puzzles
@live
async def submit_answer(page, player, answer: int):
if answer == player.correct_answer:
player.score += 1
player.puzzle = generate_new_puzzle()
return player.puzzle
The participant interacts via live methods until the timeout advances them.
See the sumhunt example · encryption_task example
Hiding the countdown display¶
Sometimes participants should not see how much time is left, for example when time pressure is part of your design. To hide the countdown, override the timeoutbox block with an empty one in your page’s template:
{% extends "Base.html" %}
{% block timeoutbox %}{% endblock timeoutbox %}
{% block title %}
Negotiation
{% endblock title %}
{% block main %}
<p>Make your offer.</p>
{% endblock main %}
The timeout still applies. Only the display is gone.
Repositioning the countdown display¶
uproot automatically shows a countdown timer in #uproot-timeout. To move it elsewhere on your page, relocate the #uproot-time-remaining element with JavaScript:
<p>Time remaining: <span id="time-here"></span></p>
<script>
document.getElementById("time-here").appendChild(
document.getElementById("uproot-time-remaining")
);
document.getElementById("uproot-timeout").remove();
</script>
This moves the countdown into your custom container and removes the default wrapper.
Checking timeout status in templates¶
Access the timeout flag in your results template:
{% if player.timed_out %}
<p>You ran out of time.</p>
{% else %}
<p>You completed the task in time.</p>
{% endif %}
Advanced: JavaScript timeout API¶
The uproot object exposes timeout state and events for custom interfaces. The
countdown uses the monotonic performance.now() clock, not a Unix deadline.
Reading timeout state¶
The current state is available in the built-in Alpine store:
For lower-level access, use uproot.pageTimeoutRemainingMs() while a timeout
is active. The pageTimeoutStartMs and pageTimeoutDurationMs properties are
also available, but the Alpine store is the more stable interface.
Timeout events¶
Two custom events fire on window:
// Fires once when the timeout is set
window.addEventListener("UprootInternalPageTimeoutSet", (event) => {
console.log("Timeout started:", event.detail);
});
// Fires every second during countdown
window.addEventListener("UprootInternalPageTimeout", (event) => {
updateCustomDisplay(event.detail);
});
Visual feedback classes¶
The default #uproot-timeout element automatically changes its timeout level
class based on the remaining time:
| Remaining time | Class added |
|---|---|
| More than 60 seconds | uproot-timeout-light |
| 15–60 seconds | uproot-timeout-warning |
| Less than 15 seconds | uproot-timeout-danger |
Custom countdown display¶
Combine these APIs for a fully custom countdown:
<div id="my-timer" class="display-4"></div>
<script>
window.addEventListener("UprootInternalPageTimeout", () => {
const secs = Math.max(0, Math.ceil(uproot.pageTimeoutRemainingMs() / 1000));
const mins = Math.floor(secs / 60);
const remainder = secs % 60;
document.getElementById("my-timer").innerText =
`${mins}:${remainder.toString().padStart(2, "0")}`;
});
</script>
To hide the default display, override the timeoutbox block as shown in Hiding the countdown display.
Summary¶
| Feature | Purpose |
|---|---|
timeout = 60 |
Static timeout in seconds |
def timeout(page, player) |
Dynamic timeout calculation |
def timeout_reached(page, player) |
Callback when timeout expires |
Return None from timeout |
Disable timeout for that player |
{% block timeoutbox %}{% endblock timeoutbox %} |
Hide the countdown display |
Alpine.store("uproot").timeout |
JavaScript: current timeout state |
UprootInternalPageTimeoutSet |
JavaScript: event when timeout starts |
UprootInternalPageTimeout |
JavaScript: event every second |