Back to blog
Article

Server Action forms that hold up on slow connections

Server Action forms that hold up on slow connections
S

StriveBit

4 min readWeb Applications

Server Action forms that hold up on slow connections

A form submits, the spinner spins, and then nothing — for eight, ten, fifteen seconds. The user taps submit again. Now there are two requests in flight, and the first one comes back with a validation error the user never sees because they've already navigated away or started a second submission. This is the failure mode we keep seeing on mid-range Android phones over 2G and 3G connections.

Next.js Server Actions give you `useFormStatus` for pending state and `useActionState` for the result, but they don't prevent double-submission or tell you what to render while the request is in flight. You have to wire that yourself.

The starting point is a form that disables its submit button and shows a state that actually communicates something is happening. We use `useFormStatus` inside a child component, not the form itself, because the hook only re-renders the component that contains the submit button.

function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? "Saving…" : "Save"}
    </button>
  );
}

export function FeeForm() {
  const [state, formAction] = useActionState(submitFee, null);
  return (
    <form action={formAction}>
      <input name="studentId" defaultValue={state?.values?.studentId} />
      {state?.error?.studentId && (
        <p>{state.error.studentId}</p>
      )}
      <SubmitButton />
    </form>
  );
}

The server action returns `{ error, values }` so the form can repopulate fields on failure. Without this, a validation error on a slow connection means the user retypes everything, which on a phone with an unpredictable keyboard is a real cost.

export async function submitFee(
  _prev: FormState | null,
  formData: FormData
): Promise<FormState> {
  const studentId = formData.get("studentId") as string;
  const errors: Record<string, string> = {};

  if (!studentId || studentId.length < 5) {
    errors.studentId = "Student ID must be at least 5 characters.";
  }

  if (Object.keys(errors).length > 0) {
    return { error: errors, values: { studentId } };
  }

  try {
    await db.fee.create({ data: { studentId } });
    revalidatePath("/fees");
    return { success: true };
  } catch (e) {
    return {
      error: { _form: "Could not save. Please try again." },
      values: { studentId },
    };
  }
}

The `disabled` attribute on the submit button prevents the double-submit problem for most users. It's not a guarantee — a determined user can remove the attribute in devtools — but it handles the common case where someone taps twice because the network is slow and they think the first tap didn't register.

One thing we stopped doing is optimistic updates on these forms. On a fast connection, showing the result before the server confirms is a reasonable bet. On a connection that might drop mid-request, the user sees a success state, the request fails, and now the UI is lying. The `pending` state from `useFormStatus` is enough — it tells the user the request is in flight, and the UI only changes when the server responds.

Network errors are the case that catches people. A validation error comes back as a normal response. A network failure — DNS timeout, connection reset, the phone switching from WiFi to cellular mid-request — doesn't come back at all. The `pending` state stays true until the browser gives up, which can be thirty seconds or more on some Android WebView versions. We add a client-side timeout that flips the pending state to an error after twenty seconds.

function SubmitButton() {
  const { pending } = useFormStatus();
  const [timedOut, setTimedOut] = useState(false);

  useEffect(() => {
    if (!pending) { setTimedOut(false); return; }
    const t = setTimeout(() => setTimedOut(true), 20000);
    return () => clearTimeout(t);
  }, [pending]);

  return (
    <button type="submit" disabled={pending}>
      {timedOut ? "Still trying…" : pending ? "Saving…" : "Save"}
    </button>
  );
}

"Still trying…" is not an error state — the request is still in flight. But it tells the user the connection is the problem, not the form. If the request eventually succeeds, the state updates normally. If it fails, the catch block in the server action returns the error, and the form shows it.

We don't use `useTransition` for form submission. It works, but `useFormStatus` is more specific to the form context and handles the pending state without extra wiring. `useTransition` is for actions triggered outside a form — a button that deletes a record, for instance, where you want a pending state on the button itself.

The validation split matters too. Client-side validation catches obvious mistakes before the round trip, which on a slow connection is the difference between a one-second correction and a fifteen-second wait. But client-side validation is a convenience, not a guarantee. The server action validates again, because the client can be bypassed. We run the same validation logic on both sides by putting it in a shared module imported by the client component and the server action.

Back to all articles

Ready to build something great?

We help ambitious teams build software that lasts. If you're interested in working with us or want to discuss your project, let's connect.

Get in touch