← Use cases

Progress for long jobs and streaming AI answers

Exports, imports, encoding and AI tasks: a progress bar that follows the real work, the result the moment it is ready, and answers that appear as they are written.

Exports, video encoding, report generation, imports and AI tasks can take from ten seconds to ten minutes. A spinner with no feedback makes people refresh, click again or give up. Showing real progress, and the result the moment it is ready, keeps them on the page. It also works for AI features: stream an assistant's answer as it is written, even when the work runs in a background job.

What your users get

  • A progress bar that moves with the real work: "Processing 3,200 of 10,000 rows".
  • A download link or result that appears the moment the job finishes, even in another tab.
  • AI answers that appear word by word instead of after a long wait.
  • The freedom to leave the page: they are told when it is done.

Built with

  • User authentication and sendToUser, so a job's updates reach only the person who started it, in every tab.
  • Private channels (private-job-<id>) when several people follow the same job.
  • Workers in any language: the official Pusher server libraries for Node.js, PHP, Python, Go, Ruby, Java and .NET can publish from a queue worker.
  • Push notifications to tell users a long job is done when they have closed the app.

Step 1: start the job, get an ID

app.post('/exports', requireLogin, async (req, res) => {
  const job = await queue.add('export', { userId: req.user.id, filters: req.body });
  res.status(202).json({ jobId: job.id });
});

Step 2: report progress from the worker

Report progress at steps that mean something, not on every row:

worker.process('export', async (job) => {
  const user = String(job.data.userId);
  const rows = await db.orders.query(job.data.filters);
  for (let i = 0; i < rows.length; i += 1000) {
    await writeChunk(rows.slice(i, i + 1000));
    await pusher.sendToUser(user, 'job-progress', { jobId: job.id, done: Math.min(i + 1000, rows.length), total: rows.length });
  }
  const url = await uploadAndSign();
  await pusher.sendToUser(user, 'job-done', { jobId: job.id, url });
});

The same in a Python worker with the pusher package:

pusher_client.send_to_user(str(user_id), "job-progress", {"jobId": job_id, "done": done, "total": total})

Step 3: show it in the browser

With the user signed in on the socket (see in-app notifications):

const { jobId } = await fetch('/exports', { method: 'POST', body: form }).then((r) => r.json());

pusher.user.bind('job-progress', (p) => {
  if (p.jobId === jobId) progress.set(p.done / p.total);
});
pusher.user.bind('job-done', (p) => {
  if (p.jobId === jobId) showDownload(p.url);
});

Streaming AI answers

When an AI model writes the answer in a background job, forward its output to the user in small batches, so a long answer is tens of messages, not thousands:

let buffer = '';
let last = Date.now();
for await (const chunk of llm.stream(prompt)) {
  buffer += chunk.text;
  if (Date.now() - last > 150 || buffer.length > 2000) {
    await pusher.sendToUser(user, 'ai-delta', { id: answerId, text: buffer });
    buffer = '';
    last = Date.now();
  }
}
await pusher.sendToUser(user, 'ai-done', { id: answerId, text: buffer });
pusher.user.bind('ai-delta', ({ id, text }) => answers.get(id).append(text));
pusher.user.bind('ai-done', ({ id, text }) => answers.get(id).append(text).finish());

Store the full answer on your server as well, so it is there after a reload.

Tips

  • Every update is one message. Updating every 1–5% or every 150 ms looks smooth and keeps a job to a few dozen messages.
  • Subscribe before you start the job, or fetch the job's status once when the page opens, so an update that was sent before the page connected is not missed.
  • If the user has closed the tab, send a push notification with the push notifications guide when the job is done.