Skip to content
Shenzhen · The Greater Bay Area · Earth

A Streaming UI for Long-Running Tasks Must Report Job State, Not Elapsed Time

Progress that comes from the job state machine is believable and cheap to build; progress that comes from a timer is a promise the server never made. This is the event log, the constraints and the SSE handler I use for quote jobs that run for minutes.

8 min read1,704 words
Next.js ArchitectureDesign EngineeringNot yet translated.

Why does a progress bar that estimates time stop being believed?

A streaming interface for a long-running task is honest only when every byte it renders traces back to a state transition the job actually recorded. Interpolate between two real events — a timer that slides a bar toward 95 percent and parks it there — and the interface begins making claims about work it cannot observe, and the first time a user catches it being wrong is the last time they read it.

I maintain a supplier quoting pipeline where one job takes 40 seconds to roughly six minutes, depending on how many rate cards it has to fetch. The first client build animated a bar from 0 to 95 percent over 45 seconds and held. It survived design review, because a demo always answers fast. The first time a real job ran for five minutes, the bar had been sitting at 95 percent for four of them, and the sales lead watching it opened a second tab and started polling the API directly. That is the cost that never shows up in a retrospective: you do not lose the progress bar, you lose the interface, and the user rebuilds a worse one on top of your API.

A progress bar that lies once is never believed again; the user replaces it with their own monitoring, and a rendering layer that was meant to reduce support traffic becomes the reason for it.

The fix is architectural rather than visual, and the ordering matters more than the code. The worker writes each transition to an append-only log in the same transaction that changes the job, the worker is the only writer, and the client renders a projection of that log with no independent notion of time. If the log is right, the stream is a thin transport and the UI has nothing left to invent. That same table is the trace, token accounting and replay record that makes a failed run reproducible, and replay is the use case that ends up paying for it.

What must the job record before any client can stream it?

The unit of progress is a transition, not a percentage. A job moves through queued, leased, running, awaiting_input, and one of two terminal states, done or failed, and each move is a row with a monotonic seq scoped to that job. Two properties carry the whole design. seq never decreases, so a client that reconnects can ask for everything after 41 and receive exactly the gap it missed, with no duplicate work and no client-side reconciliation. And every row is written inside the transaction that changed the job, so a row cannot exist for work that did not happen.

What the UI may render differs per state, and the more useful half of the specification is what it may not render:

Job stateWhat the UI may renderWhat it must not renderExits when
queuedQueue position, only if the worker reports oneAny percentage at allA worker leases the job
leased"Started"Elapsed-time progressFirst step event
runningstep 4 of 9, from step_index and step_totalA bar filled by time rather than by stepsNext step or a terminal state
awaiting_inputThe exact question and the paused stepA spinner implying work is happeningThe user answers
doneResults, and the durationNothing is forbidden; the job is overTerminal
failedFailed step, attempt count, whether a retry is queuedA bar resting at 100 percentTerminal

The wire contract that follows from that table is small, and the mapping from a database row to an event is the only place where inference is allowed:

export type JobState = 'queued' | 'leased' | 'running' | 'awaiting_input' | 'done' | 'failed';

export interface QuoteRow { sku: string; unitCostRmb: number; leadTimeDays: number }

export type JobEvent =
  | { seq: number; type: 'state'; state: JobState; at: string }
  | { seq: number; type: 'step'; step: string; index: number; total: number; at: string }
  | { seq: number; type: 'partial'; rows: QuoteRow[]; at: string };

export interface JobEventRow {
  seq: number;
  state: JobState;
  step: string | null;
  step_index: number | null;
  step_total: number | null;
  payload: unknown;
  at: Date;
}

export function toEvent(row: JobEventRow): JobEvent {
  const { seq, step, step_index, step_total, payload, at } = row;
  const timestamp = at.toISOString(); // node-postgres returns a Date for timestamptz
  if (step !== null && step_index !== null && step_total !== null) {
    return { seq, type: 'step', step, index: step_index, total: step_total, at: timestamp };
  }
  if (payload !== null) {
    return { seq, type: 'partial', rows: payload as QuoteRow[], at: timestamp };
  }
  return { seq, type: 'state', state: row.state, at: timestamp };
}

Note what the type does not contain: an etaSeconds field. There is nowhere in this contract to put an estimate, which is the point. Every renderer downstream of JobEvent can only display what a worker asserted.

How do you make the database refuse to store progress nobody measured?

A check constraint is worth more here than a code review, because a review covers the happy path and the constraint also covers the retry branch and the backfill script nobody remembers writing. I store step position as two integers, and I make the table reject a position without a denominator:

create table job_events (
  job_id      uuid        not null references jobs (id) on delete cascade,
  seq         bigint      not null,
  state       text        not null check (state in
                ('queued', 'leased', 'running', 'awaiting_input', 'done', 'failed')),
  step        text,
  step_index  integer,
  step_total  integer,
  payload     jsonb,
  at          timestamptz not null default now(),
  primary key (job_id, seq),
  constraint step_fields_are_all_or_nothing check (
    (step is null and step_index is null and step_total is null)
    or (step is not null and step_index is not null
        and step_total is not null and step_index between 1 and step_total)
  )
);

-- The only query the stream issues. Served by the primary key, no extra index needed.
select seq, state, step, step_index, step_total, payload, at
  from job_events
 where job_id = $1 and seq > $2
 order by seq
 limit 64;

If a worker cannot say how many steps a job has, it cannot claim a position in them, because the constraint demands all three columns or none of them. That single rule eliminated a class of bug I had been fixing by hand: a job whose plan changed mid-run used to keep reporting step 7 of 12 after the plan became 15 steps, which is a lie assembled from two true numbers.

How do you stream those rows out of a Next.js route handler without buffering them?

Three details decide whether this survives production, and all three sit outside the React tree. The route opts out of caching with export const dynamic = 'force-dynamic' and runs on the Node runtime, because the streaming body is not static and the database client is not edge-compatible. The response headers must forbid transformation — cache-control: no-cache, no-transform with x-accel-buffering: no — or nginx and a few managed proxies will hold the body until it is complete, which turns a live stream into a batch response with extra steps. And resume must come from the wire, not from client memory: EventSource stores the last id: field it received and replays it as a Last-Event-ID header, so a reconnect after a reload or a backgrounded tab asks for the gap instead of starting over.

import { tailJobEvents } from '@/lib/jobs';
import { toEvent } from '@/lib/job-events';

export const dynamic = 'force-dynamic';
const POLL_MS = 750;
const HEARTBEAT_MS = 15_000;
const TERMINAL = new Set(['done', 'failed']);

export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const encoder = new TextEncoder();

  const stream = new ReadableStream<Uint8Array>({
    async start(controller) {
      let cursor = Number(request.headers.get('last-event-id') ?? '0');
      let beatAt = Date.now();
      while (!request.signal.aborted) {
        const rows = await tailJobEvents(id, cursor, 64);
        for (const row of rows) {
          const event = toEvent(row);
          controller.enqueue(encoder.encode(`id: ${event.seq}\ndata: ${JSON.stringify(event)}\n\n`));
          cursor = event.seq;
        }
        if (rows.some((row) => TERMINAL.has(row.state))) break;
        if (Date.now() - beatAt > HEARTBEAT_MS) {
          controller.enqueue(encoder.encode(': keep-alive\n\n'));
          beatAt = Date.now();
        }
        await new Promise((resolve) => setTimeout(resolve, POLL_MS));
      }
      controller.close();
    },
  });

  return new Response(stream, {
    headers: {
      'content-type': 'text/event-stream; charset=utf-8',
      'cache-control': 'no-cache, no-transform',
      'x-accel-buffering': 'no',
    },
  });
}

The loop is a poll, and that is a deliberate choice rather than a shortcut. At a 750 ms interval, 400 people watching the same job is 533 queries per second against job_events — cheap per query, not free in aggregate. Switching the tail to LISTEN/NOTIFY on the channel the worker already writes to removes that multiplier entirely, and I still keep polling below roughly 200 concurrent streams per instance, because a poll survives a dropped notification and a dropped notification is a stuck UI. Deadlock risk in the other direction is real too: the notification path couples the worker's commit to the listener's health, and the worker must never wait on it.

How should the client render a job that has stopped reporting?

Staleness is the state teams skip, and it is where fabricated motion usually sneaks back in — a fallback animation for "no events yet" is the same timer with a smaller amplitude. The hook below keeps a stale flag and nothing that ticks:

import { useEffect, useState } from 'react';
import type { JobEvent, JobState, QuoteRow } from '@/lib/job-events';

export interface JobView {
  state: JobState;
  step: { name: string; index: number; total: number } | null;
  rows: QuoteRow[];
  lastEventAt: string | null;
  stale: boolean;
}

export function useJobStream(jobId: string, initialState: JobState): JobView {
  const [view, setView] = useState<JobView>({
    state: initialState, step: null, rows: [], lastEventAt: null, stale: false,
  });

  useEffect(() => {
    const source = new EventSource(`/api/jobs/${jobId}/stream`);
    source.onmessage = (message: MessageEvent<string>) => {
      setView((current) => apply(current, JSON.parse(message.data) as JobEvent));
    };
    source.onerror = () => setView((current) => ({ ...current, stale: true }));
    return () => source.close();
  }, [jobId]);

  return view;
}

function apply(view: JobView, event: JobEvent): JobView {
  switch (event.type) {
    case 'state':
      return { ...view, state: event.state, lastEventAt: event.at, stale: false };
    case 'step': {
      const step = { name: event.step, index: event.index, total: event.total };
      return { ...view, step, lastEventAt: event.at, stale: false };
    }
    case 'partial':
      return { ...view, rows: [...view.rows, ...event.rows], lastEventAt: event.at };
  }
}

Because the server closes the stream on a terminal state and EventSource auto-reconnects on any close, onerror fires for the normal end of a finished job as well as for a dropped connection. I close from the server after the terminal event and treat a reconnect whose cursor is already at the tail as a no-op, rather than teaching the client to guess which disconnect was intentional. The one rendering rule I hold to: stale shows the age of the last real event as text — "last update 47 s ago" pinned to the final step — because a frozen bar at 90 percent and a bar that is honestly waiting at 90 percent look identical, and only one of those is true.

When is streaming the wrong choice here?

Four cases, and I have shipped the boring answer in three of them.

Short jobs come first. If p95 duration is under about two seconds, await the result in the server component and render it. A stream that opens and closes in 1.2 seconds consumes a connection, a route, a hook and a reconnect path to display a spinner with a nicer font.

Connection budget comes second. HTTP/1.1 gives a browser six connections per origin, and every open EventSource holds one until it closes. Ten job cards on a dashboard each holding a stream will queue the rest of the application's requests behind them, and the symptom is reported as "the site got slow", not as "the dashboard opened too many streams". Stream the job being watched; poll the list endpoint every few seconds for the rest.

Third, jobs with no persisted state. If the worker does not write transitions, do not build the client. The alternative is synthesising events on the server from a setInterval, which is the original lie with a longer round trip and a nicer audit trail.

Fourth, unbounded work. Token-by-token output from a model has no denominator, so a percentage is meaningless even when the stream is honest. Showing the text arrive is a true statement about progress; showing 60 percent of an unknown total is not.

Symptom reportedActual causeFix at the source
"It sits at 90 percent for four minutes"The client interpolates between two real eventsRender the last step event with its timestamp; delete the timer
"Progress jumps backwards after a reload"Cursor reset to zero, events replayedHonour Last-Event-ID; seq is monotonic per job
"It hit 100 percent and then failed"The client reads the last step as successOnly a terminal done event closes the stream
"Updates stop after about a minute"A proxy buffered the body or cut an idle connectionno-transform, buffering disabled, comment heartbeat every 15 s
"Five of the ten cards stop updating"Six-connection ceiling per origin on HTTP/1.1One stream for the watched job, polling for the list

What is the first change to make?

Add the events table and the all-or-nothing constraint, then have the worker write one row per transition inside the transaction it already opens, and stop there for a day. Render the raw event list to a plain admin page with no styling and no client timer, run a few real jobs, and read what the worker actually emits — I found two states I had assumed were one, and a step whose name changed between attempts. Only after the log reads correctly is a progress bar worth building, because at that point it is a projection of facts rather than an animation that has to be maintained in step with reality. The animation was never the hard part; the reason the interface is believable is that none of its numbers are invented, and the same record that makes it believable is what you will open the morning after a job stalls.

Keep reading

More in Engineering

Ready to build a system?[ Book a Call ]