How Should a SaaS App Handle Long Running Jobs?
How Should a SaaS App Handle Long Running Jobs?
Let the user leave. Any operation that cannot finish in a few seconds should run in the background, report its state honestly, survive a page refresh, and tell the person when it is done. A modal with a spinner that holds someone hostage for four minutes is the design to avoid.
Imports, exports, report generation, bulk edits, syncs and anything touching an AI model all land in this category. Most products handle the first one they build reasonably and then handle every subsequent one differently.
Here is the pattern we use, and the specific decisions inside it that are easy to get wrong.
What Counts as Long?
There are well-established thresholds, and they are older than most of the products using them. Jakob Nielsen published three response time limits for Nielsen Norman Group on 1 January 1993, and they have held up remarkably well.
One tenth of a second is the limit for the user to feel the system is reacting instantaneously, and no special feedback is needed beyond showing the result. One second is the limit for the user's flow of thought to stay uninterrupted, though they notice the delay and lose the sense of direct manipulation.
Ten seconds is the limit for keeping the user's attention focused on the dialogue. Beyond that, Nielsen notes, users may switch tasks. That is not a failure of patience, it is what people sensibly do with their time.
So the practical dividing line is ten seconds. Under it you can keep someone waiting. Over it, you should assume they have gone somewhere else and design for their return.
Why Is a Spinner Not Enough?
Because a spinner conveys exactly one bit of information: something is happening. It cannot tell you whether the job is nearly done or stuck, and after thirty seconds most people read it as broken rather than busy.
Nielsen's guidance is specific about the alternative. Percent-done progress indicators should be used for operations taking more than about ten seconds, because they reassure users the system has not crashed, indicate how long the wait will be, and make the waiting less painful.
He also addresses the case where you genuinely cannot measure progress, suggesting some form of continuous feedback such as a spinning ball or dots on a status line, to at minimum signal that processing is occurring. That is the fallback, not the default.
The engineering objection is usually that progress is hard to calculate. Often it is, but a countable proxy is almost always available: rows processed, files uploaded, records validated. Counting something real beats animating nothing. Counting something real is also easier to explain in the interface than a bar that moves for reasons nobody can describe.
Should the User Have to Wait at All?
Usually not. The best version of this interaction is the one where waiting is optional. Start the job, confirm it started, return the person to what they were doing, and let them check back or get notified.
This changes the emotional shape of the interaction completely. A four minute job you must watch is four minutes of your life. The same job running in the background costs you the ten seconds it took to start it.
It also changes what failure means. If the person has moved on, a failure has to reach them somewhere, which forces you to build the notification path you should have built anyway. Blocking UI lets teams skip that work and then discover it is missing during an incident.
The exception is a job whose result the user genuinely cannot proceed without, and those are rarer than product teams assume. Most of the time the honest answer is that waiting was easier to build.
How Do You Tell Someone It Is Finished?
In more than one place, because you do not know where they are. In-app is the minimum: a notification centre entry and a state change on the object itself. Email or another external channel is needed for anything that might outlast the session.
Match the channel to the duration. A thirty second job needs an in-app toast. A twenty minute export needs an email, because the person has almost certainly closed the tab.
Make the notification carry the result, not just the status. "Your export is ready" with a download link is useful. "Job completed" with no link makes the person go hunting, which is a small failure repeated every single time. Our guide to SaaS notification design covers the wider system.
What Does the Progress Indicator Need to Say?
Three things: what is being done, how far along it is, and what will happen when it finishes. Most implementations get the middle one and skip the other two.
Label the work in the user's language, not the system's. "Importing 4,812 contacts" is a label. "Processing job 9f2c" is an internal identifier that leaked into the interface.
Where you can give a time estimate, give a rough one and let it be wrong rather than precise. People plan around "about two minutes" and distrust a counter that says 14 seconds remaining for a full minute.
And say what happens next before it happens. If the file will download automatically, say so. If the records will appear in a list, say which list. That one sentence removes most of the follow-up support questions these features generate.
How Do You Make This Accessible?
Use the right role and keep it labelled. The WAI-ARIA specification defines progressbar as a bar displaying the progress status of a task. The role requires a name, so it needs an accessible label rather than relying on nearby text.
It supports aria-valuenow, aria-valuemin, aria-valuemax and aria-valuetext. Indeterminate progress has a defined expression: omit aria-valuenow, and the task is understood to be ongoing with an unknown completion percentage. That is better than inventing a fake percentage that never moves.
One detail catches teams out. Progressbar is not a live region role. The specification lists live region roles as alert, log, marquee, status and timer, and places progressbar under document structure roles instead. So a screen reader will not automatically announce progress updates.
The fix is to announce meaningful milestones through a status region rather than streaming every percentage change, which would be unusable. Also note that the ARIA Authoring Practices Guide is explicit that the meter role should not be used to indicate progress such as loading or percent completion, and directs you to progressbar instead. Our piece on accessibility in product UI covers the surrounding patterns.
What Happens When the Job Fails Halfway?
You have to decide in advance whether partial work counts, and then say so in the interface. An import that processed 3,000 of 5,000 rows before failing leaves the user in a state they cannot reason about unless you explain it.
Two honest designs exist. All or nothing, where a failure rolls everything back and the user retries cleanly. Or partial with a record, where the successful work stands and the user gets a list of exactly what failed and why.
The second is more useful and more work. The list of failures has to be specific enough to act on, which means row numbers and reasons, not a count. A message saying 2,000 records failed is a notification of despair.
What you must never do is fail silently into a success state. Our guide to SaaS error message design covers how to write the failure itself.
Can the User Cancel?
They should be able to, and cancelling must mean something specific. The worst version is a cancel button that stops the interface from watching while the job carries on writing to the database.
Decide what cancel does and label it accordingly. If it aborts and rolls back, say Cancel and undo. If it stops further processing but keeps what has been done, say Stop, and tell the person what state they will be left in.
For jobs that genuinely cannot be interrupted, do not show a cancel affordance at all. A disabled or fake cancel button is worse than none, because it teaches people that your controls do not do what they say.
What Should Happen on Refresh or a Second Tab?
The job should still be there. This is the test that separates a real background job from a spinner with ambitions. Refresh the page mid-run and the progress should reappear, because the state lives on the server rather than in the browser.
The same applies across tabs and devices. Someone who starts an export on a laptop and opens the app on another machine should see it running. If they see nothing, they will start it again, and now you have two.
Duplicate submission is the most common data problem these features create. Guard against it at the server, not by disabling a button, because the button is not where the second request comes from. Our piece on import and export UX covers the rest of that flow.
Where Would We Start?
List every operation in your product that can exceed ten seconds and check each against four questions. Does it block the user. Does it survive a refresh. Does it tell someone when it finishes. Does it explain a partial failure.
Most teams find the answers differ per feature, because each was built by a different person at a different time. Standardising the pattern once is usually less work than it looks, and it removes a whole category of support tickets.
If you want help designing that pattern, or auditing the long-running operations already in your product, we are happy to walk through it. You can find us at phoenix.studio.
Want a site that performs like this?
Tell us about your project. We will come back with a clear next step, no pressure.
This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.
Have a project like this?
Tell us where you want to go. We'll tell you how we'd get you there.