---
url: https://formhaus.dev/recipes/question-timing.md
description: >-
  Measure how long visitors spend on each question of a Formhaus form with form
  events: step_viewed, form_submitted and form_submit_failed.
---

# How do I measure time per question?

Listen to form events and time each question from its `step_viewed` to the next step, the submit or the visitor leaving. The form library only reports events. The timer below is your code.

## Definition

One question per step:

```json
{
  "id": "survey",
  "title": "Quick survey",
  "submit": { "label": "Send" },
  "steps": [
    {
      "id": "role",
      "title": "What is your role?",
      "fields": [
        {
          "key": "role",
          "type": "radio",
          "label": "Role",
          "autoAdvance": true,
          "validation": { "required": true },
          "options": [
            { "value": "engineer", "label": "Engineer" },
            { "value": "designer", "label": "Designer" },
            { "value": "other", "label": "Other" }
          ]
        }
      ]
    },
    {
      "id": "team",
      "title": "How big is your team?",
      "fields": [{ "key": "team", "type": "number", "label": "People", "validation": { "required": true, "min": 1 } }]
    },
    {
      "id": "feedback",
      "title": "Anything else?",
      "fields": [{ "key": "feedback", "type": "textarea", "label": "Feedback" }]
    }
  ]
}

```

## Timer

Save as `question-timing.ts`:

```ts
import type { FormEvent } from '@formhaus/core';

export interface QuestionTime {
  stepId: string;
  fieldKey?: string;
  ms: number;
}

function stopwatch(now: () => number) {
  let spent = 0;
  let since: number | undefined;
  return {
    start() {
      since ??= now();
    },
    pause() {
      if (since !== undefined) spent += now() - since;
      since = undefined;
    },
    take() {
      this.pause();
      const ms = spent;
      spent = 0;
      return ms;
    },
  };
}

export function questionTimer(report: (time: QuestionTime) => void, now = () => performance.now()) {
  const step = stopwatch(now);
  const field = stopwatch(now);
  let stepId: string | undefined;
  let fieldKey: string | undefined;
  let active = false;
  let hidden = false;

  const finishField = () => {
    const ms = field.take();
    if (stepId && fieldKey) report({ stepId, fieldKey, ms });
    fieldKey = undefined;
  };
  const finishStep = () => {
    if (fieldKey) finishField();
    const ms = step.take();
    if (stepId && ms > 0) report({ stepId, ms });
    active = false;
  };
  const resume = () => {
    active = true;
    if (!hidden) step.start();
  };

  return {
    onEvent(event: FormEvent) {
      if (event.type === 'form_viewed') {
        step.take();
        field.take();
        stepId = fieldKey = undefined;
        active = false;
      } else if (event.type === 'step_viewed') {
        finishStep();
        stepId = event.stepId;
        resume();
      } else if (event.type === 'field_focused') {
        fieldKey = event.fieldKey;
        if (!hidden) field.start();
      } else if (event.type === 'field_blurred' && event.fieldKey === fieldKey) {
        finishField();
      } else if (event.type === 'form_submitted' || event.type === 'form_reset') {
        finishStep();
      } else if (event.type === 'form_submit_failed') {
        resume();
      }
    },
    hide() {
      hidden = true;
      step.pause();
      field.pause();
    },
    show() {
      hidden = false;
      if (active) step.start();
      if (fieldKey) field.start();
    },
    leave() {
      finishStep();
      stepId = undefined;
    },
  };
}

export type QuestionTimer = ReturnType<typeof questionTimer>;

export function watchPage(timer: QuestionTimer, page: Document = document, view: Window = window) {
  const visibility = () => (page.visibilityState === 'hidden' ? timer.hide() : timer.show());
  const leave = () => timer.leave();
  visibility();
  page.addEventListener('visibilitychange', visibility);
  view.addEventListener('pagehide', leave);
  return () => {
    page.removeEventListener('visibilitychange', visibility);
    view.removeEventListener('pagehide', leave);
  };
}

```

## React

```tsx
import { FormRenderer } from '@formhaus/react';
import { parseDefinition } from '@formhaus/core';
import { useEffect, useMemo } from 'react';
import survey from './survey.json';
import { questionTimer, watchPage } from './question-timing';
import { track } from './analytics';
import { save } from './api';

const definition = parseDefinition(survey);

export function Survey() {
  const timer = useMemo(() => questionTimer((time) => track('question_time', time)), []);
  useEffect(() => watchPage(timer), [timer]);
  return <FormRenderer definition={definition} onSubmit={save} onFormEvent={timer.onEvent} />;
}
```

## Vue

```vue
<script setup lang="ts">
import { FormRenderer } from '@formhaus/vue';
import { parseDefinition } from '@formhaus/core';
import { onBeforeUnmount, onMounted } from 'vue';
import survey from './survey.json';
import { questionTimer, watchPage } from './question-timing';
import { track } from './analytics';
import { save } from './api';

const definition = parseDefinition(survey);
const timer = questionTimer((time) => track('question_time', time));
let stop = () => {};
onMounted(() => (stop = watchPage(timer)));
onBeforeUnmount(() => stop());
</script>

<template>
  <FormRenderer :definition="definition" @submit="save" @form-event="timer.onEvent" />
</template>
```

## Without a renderer

```ts
import { FormEngine } from '@formhaus/core';

const engine = new FormEngine(definition, {}, { onFormEvent: timer.onEvent });
engine.start();
```

Call `start()` when the form is on screen. It reports `form_viewed` and the first `step_viewed` once per engine.

## How it works

* Each mounted form gets its own timer. `form_viewed` clears it, so a remount or a new definition starts from zero.
* `step_viewed` fires for the first step and for every later step change: Continue, Back, Skip, auto-advance, a jump to an error and a reset.
* `form_submitted` ends the last question. `form_submit_failed` restarts its clock, because the visitor stays on it.
* `field_focused` to `field_blurred` gives the time spent in each field. Those reports carry `fieldKey`.
* `watchPage` pauses both clocks while the tab is hidden and reports the open question on `pagehide`, so abandoned questions are counted.
* Single-page app navigation does not fire `pagehide`. Call `timer.leave()` when the route changes away from the form.
* A question the visitor returns to is reported again. Sum the reports per `stepId` for the total.

## Related

* [Form events](/guide/custom-components#form-events): every event and its payload
* [Quiz funnel](/recipes/quiz-funnel): one-click questions with `autoAdvance`
* [Headless engine](/recipes/headless-engine): `FormEngine` without React or Vue
