Skip to content

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:

class TimedTask(Page):
    timeout = 60  # Auto-advance after 60 seconds

When the timeout expires, the participant moves on to the next page. uproot saves what they entered if it is valid; see What happens to entered data.

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

What happens to entered data

When time runs out, the browser submits the page with whatever the participant has entered so far. uproot then:

  1. runs timeout_reached,
  2. checks the submitted data as usual, with built-in validation, validate, and handle_stealth_fields,
  3. saves the data only if it passes all checks,
  4. moves the participant to the next page in any case.

The same happens to any request that arrives after the deadline, also if the participant clicked “Back” at the last moment: they move on, not back.

So a timeout never shows error messages, and it never keeps a participant on the page. If the entered data are incomplete or invalid, none of the page’s fields are saved, not even the valid ones. If they are valid, they are saved and overwrite any values that timeout_reached set for the same fields.

If the participant’s browser is closed when time runs out, nothing happens until they return. As soon as they load the page again, timeout_reached runs and the page advances.

Fields can be missing after a timeout

After a timeout, a page’s fields may not exist at all. Reading them later fails with an error such as AttributeError: Player(*('nvfyk', 'ic9uj')) has no .answer. Set a default in timeout_reached. Answers that were entered in time and are valid replace the default:

class Question(Page):
    timeout = 30
    fields = dict(answer=IntegerField(label="Your estimate"))

    @classmethod
    def timeout_reached(page, player):
        player.timed_out = True
        player.answer = None  # Replaced if a valid answer is submitted

Note that timed_out is True even if the participant’s valid answer was saved at the last moment. To tell the two cases apart, check whether player.answer is None.

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

By default, uproot shows the countdown above your page content. To show it somewhere else, empty the timeoutbox block and place the countdown yourself with one of two template helpers:

  • timeout_box() renders the complete box, including the text “Remaining time on this page:”, and hides it while no timeout is active.
  • timeout() renders only the remaining time, for example “1:05 minutes” or “42 seconds”, so you can put it inside your own text.
{% extends "Base.html" %}

{% block timeoutbox %}{% endblock timeoutbox %}

{% block title %}
Estimation task
{% endblock title %}

{% block main %}
<p>Time left: {{ timeout(class_="fw-bold") }}</p>

{{ fields() }}

{{ timeout_box(class_="alert alert-info") }}
{% endblock main %}

Both helpers accept HTML attributes as keyword arguments. Write class_ (with an underscore) for the class attribute, because class is a reserved word in Python. You can use the helpers several times on one page; all copies count down together.

The default block also contains a hidden element that announces the remaining time to screen readers when time gets short. If you empty the block and want to keep this, add it to your template:

<div id="uproot-timeout-announcer" class="visually-hidden" aria-live="assertive" aria-atomic="true"></div>

See the button_placement example

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

uproot.pageTimeoutRemainingMs() returns the remaining time in milliseconds (0 if no timeout is active). The timeout events below also pass the full state as event.detail:

// { active, level, text, compact }
// active:  whether a timeout is running
// level:   "light", "warning", or "danger" (see below)
// text:    for example "1 minute and 5 seconds"
// compact: for example "1:05 minutes" or "42 seconds"

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.text);
});

// Fires every second during countdown
window.addEventListener("UprootInternalPageTimeout", (event) => {
    console.log("Remaining:", event.detail.compact);
});

Visual feedback classes

Every countdown box from timeout_box(), including the default one, gets a class for the current timeout level:

Remaining time Level Class added
More than 60 seconds light uproot-timeout-light
60 seconds or less warning uproot-timeout-warning
15 seconds or less danger uproot-timeout-danger

To change the thresholds, set uproot.pageTimeoutWarningSeconds and uproot.pageTimeoutDangerSeconds in your page’s JavaScript.

Custom countdown display

Combine these APIs for a fully custom countdown:

<div id="my-timer" class="display-4"></div>

<script>
window.addEventListener("UprootInternalPageTimeout", (event) => {
    const timer = document.getElementById("my-timer");
    timer.innerText = event.detail.compact;
    timer.classList.toggle("text-danger", event.detail.level === "danger");
});
</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
{{ timeout_box() }} / {{ timeout() }} Place the countdown box or the bare remaining time
uproot.pageTimeoutRemainingMs() JavaScript: remaining time in milliseconds
UprootInternalPageTimeoutSet JavaScript: event when timeout starts
UprootInternalPageTimeout JavaScript: event every second