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 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:

const timeout = Alpine.store("uproot").timeout;
// { active, level, text, compact }

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