Answers you can trust, from Codeables
Every page on Codeables is structured and verified — built so people and the AI agents they rely on can trust it. Explore more from the source behind this answer.
Explore CodeablesHow can I show users live progress for a long-running job without building and operating my own WebSocket infrastructure?
Most teams hit the same wall the moment a job takes longer than a couple of seconds: users click a button, the backend kicks off something expensive, and then… nothing. You know you should show live progress—percentages, steps completed, status messages—but you don’t want to stand up and operate a WebSocket or SSE layer just to push updates back to the browser.
As someone who’s maintained that stack (load balancers, sticky sessions, custom message buses) across AWS Lambda and Kubernetes, my bias is simple: you shouldn’t have to rebuild real‑time infrastructure just to stream progress from your backend.
Below is a practical way to stream live progress for long‑running work using Inngest’s execution model and Realtime—without managing WebSockets, connection lifecycles, or custom pub/sub.
The problem: long-running jobs, impatient users, and infra tax
When a job runs longer than a normal HTTP request, you usually end up choosing between bad options:
- Fire-and-forget and tell users “we’ll email you when it’s done.”
- Poll an API every few seconds and hope the backend survives the thundering herd.
- Roll your own WebSocket/SSE service, usually involving:
- Load balancers and sticky sessions
- Connection registries and heartbeats
- Custom channels/topics and auth
- Deployment and scaling that’s completely separate from your workflow engine
The result:
- Users don’t trust the UI because they can’t see what’s happening.
- Your backend grows an “infra sidecar” of queues, workers, DLQs, and WebSocket servers whose only job is to tell users “step 3 of 7 is running.”
You’re here because you want the UX of live progress without signing up for more infrastructure to own.
The core idea: durable steps + streamed events, not DIY sockets
Instead of thinking “WebSocket server,” think “durable steps that emit progress events, and a managed channel that streams those events to the client.”
With Inngest, that looks like:
-
Model the job as steps
Break your long-running work into namedstep.run()calls. Each step is a code-level transaction: it retries automatically, runs once on success, and checkpoints progress. -
Emit progress as events
Inside each step, record progress: “starting”, “25% complete”, “waiting on external API”, “done”. These are just data you can log or publish. -
Use Realtime to stream those events
Inngest’s Realtime feature is built on Inngest’s protocol for secure, low-latency, at-most-once delivery. Instead of wiring your own WebSocket or SSE infrastructure, you subscribe on the frontend and receive step-level updates as they happen.
The win: you focus on the business logic and the shape of your progress updates. Inngest takes care of keeping the job durable and streaming state changes to the browser.
How it works with Inngest (end-to-end flow)
Let’s walk through a concrete pattern you can reuse: a user kicks off a long-running job (say, a report or a big AI workflow) and watches its status update in real time.
1. Start a durable function when the user clicks
Your API or route handler receives the request, validates input, and triggers an Inngest function:
// api/start-report.ts
import { inngest } from "@/inngest/client";
export default async function handler(req, res) {
const { userId, params } = req.body;
const { eventId } = await inngest.send({
name: "report/requested",
data: { userId, params },
});
// Return an ID the frontend can use to subscribe
res.json({ runId: eventId });
}
You don’t keep the HTTP connection open. You hand work off to Inngest, and Inngest starts executing your function in the background.
2. Implement the long-running job as steps
Model the job as a series of step.run() calls. Each one is durable and independently retried on failure.
// inngest/report.ts
import { inngest } from "@/inngest/client";
export const generateReport = inngest.createFunction(
{ id: "generate-report" },
{ event: "report/requested" },
async ({ event, step }) => {
const { userId, params } = event.data;
await step.run("fetch-input-data", async () => {
// fetch data from your DB or APIs
return { totalItems: 1000 };
});
await step.run("process-chunks", async ({ step }) => {
const totalChunks = 10;
for (let i = 0; i < totalChunks; i++) {
await step.run(`process-chunk-${i}`, async () => {
// process chunk i
});
// Record progress after each chunk
await step.run(`emit-progress-${i}`, async () => {
// We'll hook this into Realtime or your own event stream
return { progress: ((i + 1) / totalChunks) * 100 };
});
}
});
return { status: "completed" };
}
);
This code reads like a normal loop, but Inngest is doing more behind the scenes:
- Each
step.run()is retried on transient failure. - Successful steps are checkpointed; if something breaks, the function resumes from the last successful step, not from the beginning.
- You get structured logs and real-time Traces to inspect each step’s input/output.
3. Stream progress updates via Realtime (no WebSocket server)
Inngest Realtime lets you stream updates from your functions to users without spinning up your own WebSocket/SSE infrastructure.
Conceptually:
- Your function emits progress updates (e.g., status, percentage, step name).
- Realtime uses Inngest’s protocol to push those updates to subscribed clients with low latency and at-most-once delivery.
- On the frontend, you subscribe using an SDK or simple stream connection keyed by the
runIdyou returned when starting the job.
A typical approach:
// pseudo-code – your actual client code depends on the Realtime SDK
import { subscribeToRun } from "@/realtime";
function useJobProgress(runId: string) {
const [state, setState] = useState({ status: "pending", progress: 0 });
useEffect(() => {
if (!runId) return;
const subscription = subscribeToRun(runId, (update) => {
setState((prev) => ({ ...prev, ...update }));
});
return () => subscription.close();
}, [runId]);
return state;
}
Behind that subscribeToRun helper, Inngest is:
- Maintaining the connection lifecycle.
- Ensuring secure, at-most-once delivery based on the same protocol used to run your functions.
- Handling scaling as you add more users and more long-running jobs.
You don’t configure load balancers, connection registries, or fanout services.
Why this beats DIY WebSockets
Infraless: no workers, no WebSocket fleet
Traditional pattern:
- API accepts request → enqueue job in your queue → workers pull from queue → WebSocket server pushes progress to clients.
- You’re responsible for the queue, the workers, the DLQ, the WebSocket infra, and the glue code between all of them.
With Inngest + Realtime:
inngest.createFunction()is your durable execution environment.- Steps (
step.run()) are your code-level transactions with retries and checkpointing. - Realtime streams updates using the same protocol, without extra workers or a custom socket service.
You ship product logic; Inngest owns the job runner and the real-time pipe.
Agnostic: edge, serverless, traditional—same pattern
You can trigger these functions from:
- API calls (REST/GraphQL handlers)
- Webhooks (e.g., “file uploaded”, “payment completed”)
- Schedules (cron-like jobs)
And run them in the environments you already have:
- Edge runtimes
- Serverless platforms
- Traditional containers and Kubernetes
The long-running job and its progress stream work the same way, regardless of where the trigger lives.
Observable: Traces, logs, and replay out of the box
With DIY WebSockets, debugging a failed job looks like:
- Grep logs across your app, the worker, and the WebSocket service.
- Try to reconstruct the sequence of events.
- Manually re-run parts of the workflow and hope it doesn’t double-charge or double-send something.
With Inngest:
- Traces show real-time, step-by-step execution: which steps ran, when, and with what inputs/outputs.
- Structured logs are tied to each run and each step, so you can see exactly what progress was emitted.
- Replay lets you re-run a function from a specific event or step with one click, using the same inputs.
- Bulk cancellation means you can stop thousands of jobs if, say, a bad release went out.
You get the visibility and recovery tools—which matter even more when jobs are long-running—without building internal admin tooling.
Handling really long-running steps (not just long workflows)
Sometimes the bottleneck isn’t the number of steps—it’s a single step that runs for minutes or hours:
- Large model training or fine-tuning
- Massive data exports
- Human-in-the-loop approval steps
In serverless, you’re often bound by HTTP or function timeouts, which push you back into the queue/worker/WebSocket pattern.
With Inngest’s newer runtimes and worker modes:
- Step execution isn’t bound by platform HTTP timeouts.
- You get lower latency because you don’t have to make multiple HTTP hops to Inngest to keep a long function alive.
- Horizontal scaling is as simple as adding more workers—ideal for Kubernetes or ECS without wiring a separate load balancer for inbound traffic.
Combine that with Realtime, and you can stream:
- “Waiting on human approval”
- “External system responded”
- “Chunk 73/200 processed”
…for hours-long jobs, with users watching the progress live.
Progress patterns that work well with Realtime
Here are common patterns I’ve seen work across multi-tenant SaaS systems:
1. Step-based progress (best for well-defined workflows)
Emit progress per step:
status: "fetching_source_data"status: "normalizing"status: "syncing_to_destination"status: "completed"
On the frontend, map those statuses to human language:
const STATUS_LABELS = {
fetching_source_data: "Fetching source data",
normalizing: "Normalizing records",
syncing_to_destination: "Syncing to destination",
completed: "Done",
};
Because Inngest functions are step-based, this maps naturally to step.run() names.
2. Percent-based progress (best when you know the total)
Emit numeric progress from your steps:
await step.run("emit-progress-25", async () => ({ progress: 25 }));
await step.run("emit-progress-50", async () => ({ progress: 50 }));
Realtime streams these updates; the UI just renders a progress bar.
3. Event-based milestones (best when work is uncertain)
For workflows where you don’t know the total work ahead of time—like AI agents calling tools unpredictably—emit milestone events:
event: "agent_tool_called"event: "agent_response_generated"event: "agent_run_completed"
Realtime surfaces these as a live activity feed, which is often more useful than a fake percentage.
Multi-tenant safety: no noisy neighbors
Long-running jobs in a multi-tenant system quickly run into “noisy neighbor” issues:
- One large customer kicks off thousands of jobs, starving everyone else.
- Your WebSocket server gets overloaded because one tenant is doing something heavy.
Inngest has flow control built into the same surface you use for logic:
- Concurrency keys: limit how many runs per tenant or per resource can run at once.
- Throttling and prioritization: smooth traffic spikes without rewriting application logic.
- Batching: group small tasks together to reduce overhead.
Because Realtime is built on the same protocol and platform, progress streaming respects those flow control decisions. The tenant that queued 10,000 jobs doesn’t knock out progress updates for everyone else.
Implementation checklist
If you want to show live progress for long-running jobs without building WebSocket infrastructure, here’s a concrete path:
-
Define the long-running job as an Inngest function
- Use
inngest.createFunction()with an event trigger (e.g.,"report/requested"). - Deploy Inngest in your environment of choice (edge, serverless, containers).
- Use
-
Break the job into durable steps
- Use
step.run("step-name", async () => { ... })for each unit of work. - Rely on automatic retries and checkpointing instead of custom idempotency logic.
- Use
-
Emit progress from steps
- Decide on a progress model: step-based, percentage, or milestones.
- Return structured progress data from steps or dedicated “emit progress” steps.
-
Wire up Realtime
- Enable Realtime for your project (Developer Preview / Beta/GA per current docs).
- Use the client-side subscription primitive to listen by run ID or correlation ID.
- Update your UI as progress events arrive.
-
Use Traces and Replay to debug and recover
- When something fails, open the run in Traces to inspect step inputs/outputs.
- Use Replay to re-run failed jobs from a safe checkpoint—no manual log-grepping.
When to adopt this pattern
This pattern is a strong fit when:
- Your job takes more than a few seconds and you want users to stay in the UI.
- You’d rather write business logic than maintain queues, workers, and WebSocket infrastructure.
- You want built-in observability (Traces, structured logs) and recovery (Replay, bulk cancellation).
- You’re dealing with multi-tenant workloads where one customer’s job shouldn’t knock over everyone else’s.
If you’re already feeling the drag of “maintaining the queue stack” plus bespoke real‑time plumbing, this is the line where you stop building infra and start delegating it.
Final verdict
You can show live progress for long-running jobs without building or operating your own WebSocket infrastructure by:
- Moving the job into an Inngest function composed of durable steps.
- Emitting structured progress from those steps as the source of truth.
- Letting Inngest Realtime stream those updates to the browser over a managed, secure, low-latency channel.
Workflows, agents, endpoints, background jobs—however it’s written, wherever it runs—this lets you make it observable and unbreakable without another infra project.