In modern web and desktop applications, users routinely trigger operations that cannot complete in a single render cycle - file uploads, video transcoding, AI generation, data exports, batch imports, report generation, and complex calculations. Unlike a simple API call that resolves in milliseconds, these processes can take seconds, minutes, or even hours. How your interface communicates progress, allows cancellation, and survives navigation defines whether users trust your application or abandon it mid-task.
Key Takeaway
- Always model four states - queued, running, completed, and failed - for every long-running process, no exceptions.
- Show determinate progress (with a percentage or ETA) whenever the duration is predictable; never leave users guessing how long they have to wait.
- Decouple long-running work from the component lifecycle - the user must be free to navigate without aborting the task.
- Provide cancellation for any process that takes longer than three seconds; users must always feel in control.
- Use Web Workers, background tabs, or server-side jobs to keep the main thread responsive during heavy computation.
- Persist process state across reloads and navigations so users can recover after refresh, network drops, or accidental tab closures.
- Notify users on completion through in-app toasts, browser notifications, or email - never expect them to keep watching the screen.
Table of Contents
- Introduction
- Understanding the Four Process States
- Communicating Progress - Determinate, Indeterminate & Multi-Stage
- Handling Cancellation, Failure & Recovery
- Building a Custom useLongRunningTask Hook
- Advanced Techniques
- Stats & Interesting Facts
- FAQ
- Conclusion
1. Introduction
A long-running process is any operation whose duration exceeds the user's natural attention span for a single interaction - generally anything beyond two to three seconds. Examples are everywhere in modern software: uploading a 500 MB video, running an AI image generation job, exporting a 100,000-row spreadsheet, transcoding audio, performing a database migration triggered from an admin panel, or compiling a project in a browser-based IDE.
These processes share a common trait: the user initiates them, then has nothing to do but wait. If your interface handles that wait poorly - by freezing, hiding, or silently failing - users assume your application is broken. Worse, they may close the tab, lose their work, and never return.
Done well, long-running process UX feels almost magical. The user uploads a file, sees a clear progress indicator, navigates to another page, gets a notification when the upload finishes, and never once feels trapped. Done badly, the same operation produces a frozen browser tab, an indefinite spinner, and a frustrated user.
This article covers a complete strategy for handling long-running processes in modern web UIs - from foundational state modeling and progress indicators to background workers, cancellation patterns, resumable uploads, and cross-session persistence.
2. Understanding the Four Process States
Unlike short API calls (which only need three states), long-running processes require a richer state model. Every long-running task should be in exactly one of these four states at any given moment:
2.1 The Queued State
The queued state exists from the moment the user triggers the action until the process actually begins executing. This is non-trivial: many systems queue work behind a rate limiter, a worker pool, or a server-side job queue. The user needs to know their request was accepted, where they are in the queue, and roughly when execution will start. Skipping this state and jumping straight to "running" lies to the user when there is real waiting time before any work happens.
2.2 The Running State
The running state covers the actual execution. This is where progress communication matters most. Depending on the nature of the task, you may know the percentage complete (a file upload), the current step out of N (a multi-stage pipeline), or only that work is happening (an open-ended AI generation). Each calls for a different visual treatment.
2.3 The Completed State
When the process finishes successfully, the UI must transition into a clear success state - ideally with a summary of what was produced, a link to the result, and an action the user can take next. A common mistake is to silently return to the previous screen, which leaves users uncertain whether the task actually succeeded.
2.4 The Failed or Cancelled State
Any failure - whether from a server error, a timeout, a quota exceedance, or an explicit user cancellation - must surface to the user with a clear explanation and, where applicable, a way to retry or resume. Cancellation deserves special treatment: it was intentional, so the message should confirm rather than alarm.
A foundational pattern using a single status enum keeps these states explicit:
import { useState, useReducer } from 'react';
const initialState = {
status: 'idle', // idle | queued | running | completed | failed | cancelled
progress: 0,
result: null,
error: null,
};
function processReducer(state, action) {
switch (action.type) {
case 'queue': return { ...initialState, status: 'queued' };
case 'start': return { ...state, status: 'running', progress: 0 };
case 'progress': return { ...state, progress: action.value };
case 'complete': return { ...state, status: 'completed', progress: 100, result: action.result };
case 'fail': return { ...state, status: 'failed', error: action.error };
case 'cancel': return { ...state, status: 'cancelled' };
case 'reset': return initialState;
default: return state;
}
}
function ExportButton() {
const [state, dispatch] = useReducer(processReducer, initialState);
async function startExport() {
dispatch({ type: 'queue' });
const job = await api.createExportJob();
dispatch({ type: 'start' });
const unsubscribe = subscribeToJob(job.id, {
onProgress: (value) => dispatch({ type: 'progress', value }),
onComplete: (result) => dispatch({ type: 'complete', result }),
onError: (error) => dispatch({ type: 'fail', error: error.message }),
});
return unsubscribe;
}
if (state.status === 'idle') return <button onClick={startExport}>Start Export</button>;
if (state.status === 'queued') return <QueuedIndicator />;
if (state.status === 'running') return <ProgressBar value={state.progress} />;
if (state.status === 'completed') return <DownloadLink href={state.result.url} />;
if (state.status === 'failed') return <ErrorPanel message={state.error} onRetry={startExport} />;
if (state.status === 'cancelled') return <CancelledNotice onRestart={startExport} />;
}
3. Communicating Progress - Determinate, Indeterminate & Multi-Stage
The single biggest factor in how users tolerate waiting is whether they can predict how much longer they have to wait. Research in UX psychology consistently shows that perceived duration is far more important than actual duration - and the right progress indicator can make a 30-second wait feel shorter than a 10-second wait with no feedback.
3.1 Determinate Progress Bars - When You Know the Total
A determinate progress bar shows a percentage, an ETA, or both. Use it whenever you can reasonably estimate the total work - file uploads (you know the byte size), batch imports (you know the row count), or paginated data exports (you know the page count). Always pair the visual bar with a numeric percentage and, if possible, an estimated time remaining.
function ProgressBar({ value, etaSeconds, label }) {
return (
<div className="progress-container" role="progressbar"
aria-valuenow={value} aria-valuemin={0} aria-valuemax={100}
aria-label={label}>
<div className="progress-track">
<div className="progress-fill" style={{ width: `${value}%` }} />
</div>
<div className="progress-meta">
<span>{Math.round(value)}%</span>
{etaSeconds != null && <span>{formatEta(etaSeconds)} remaining</span>}
</div>
</div>
);
}
function formatEta(seconds) {
if (seconds < 60) return `${Math.round(seconds)}s`;
if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
return `${(seconds / 3600).toFixed(1)}h`;
}
3.2 Indeterminate Indicators - For Unknown Durations
Some tasks have genuinely unknowable duration: an AI text generation, a complex SQL query, or any process behind an opaque external service. In these cases, a determinate bar would be a lie. Use an indeterminate animation - a sweeping bar, a pulsing shape, or a series of step descriptions - to communicate "we are working" without falsely promising a specific finish time. Where possible, replace pure motion with descriptive status text that updates as the task progresses ("Analyzing input...", "Generating response...", "Finalizing output...").
function IndeterminateProgress({ stage }) {
return (
<div className="indeterminate" role="status">
<div className="indeterminate-bar" />
<p className="stage-label">{stage || 'Working...'}</p>
</div>
);
}
// CSS
/*
.indeterminate-bar {
position: relative;
height: 4px;
background: #e0e0e0;
overflow: hidden;
border-radius: 2px;
}
.indeterminate-bar::after {
content: '';
position: absolute;
height: 100%;
width: 30%;
background: #4a90e2;
animation: slide 1.4s ease-in-out infinite;
}
@keyframes slide {
0% { left: -30%; }
100% { left: 100%; }
}
*/
3.3 Multi-Stage Progress
Many real-world processes are pipelines: upload → validate → process → export → notify. A single progress bar collapses this into one number, hiding useful information. A stepper or checklist UI exposes each stage individually, so the user sees not only "we are 60% done" but "we just finished processing and are now exporting." This builds confidence that the system is making real progress, especially for long pipelines where any single stage may pause for a while.
function MultiStageProgress({ stages, currentStage }) {
return (
<ol className="stage-list">
{stages.map((stage, i) => {
const status = i < currentStage ? 'done' : i === currentStage ? 'active' : 'pending';
return (
<li key={stage.id} className={`stage stage-${status}`}>
<span className="stage-icon">
{status === 'done' && '✓'}
{status === 'active' && <Spinner />}
{status === 'pending' && '○'}
</span>
<span className="stage-label">{stage.label}</span>
{status === 'active' && stage.detail && (
<span className="stage-detail">{stage.detail}</span>
)}
</li>
);
})}
</ol>
);
}
The function of design is letting design function. - Micha Commeren
4. Handling Cancellation, Failure & Recovery
A process that cannot be cancelled is a trap. A process that fails without explanation is worse. Robust long-running UX treats cancellation, failure, and recovery as first-class concerns rather than afterthoughts.
4.1 Cancellation - Always Available, Always Clean
Any process taking longer than three seconds should expose a cancel control. Cancellation must be honest: if the work is already partially complete on the server, the UI must explain what happens to that partial result (kept, discarded, or rolled back). Silent or unreliable cancellation - where the button does nothing or the work continues anyway - destroys user trust faster than an outright crash.
function CancellableUpload({ file }) {
const [progress, setProgress] = useState(0);
const [status, setStatus] = useState('idle');
const controllerRef = useRef(null);
async function start() {
const controller = new AbortController();
controllerRef.current = controller;
setStatus('running');
try {
await uploadFile(file, {
signal: controller.signal,
onProgress: setProgress,
});
setStatus('completed');
} catch (err) {
if (err.name === 'AbortError') {
setStatus('cancelled');
} else {
setStatus('failed');
}
}
}
function cancel() {
controllerRef.current?.abort();
}
return (
<div>
{status === 'running' && (
<>
<ProgressBar value={progress} />
<button onClick={cancel}>Cancel upload</button>
</>
)}
{status === 'cancelled' && (
<p>Upload cancelled. No data was saved.</p>
)}
</div>
);
}
4.2 Distinguishing Failure Modes
Long-running failures are not all the same. Group them into categories your UI can respond to differently:
- Recoverable network failures - The connection dropped mid-upload. Offer automatic retry, ideally resuming from the last successful chunk rather than restarting.
- Server-side processing errors - The server accepted the input but couldn't complete the work (transcoding error, malformed data row, AI safety rejection). Show the specific reason and, where possible, a way to fix and retry.
- Quota or limit errors - The user hit a plan limit, rate limit, or storage cap. Surface the specific limit and a clear path to resolve it (upgrade, free up space, wait).
- Timeouts - The process exceeded a maximum allowed duration. Communicate this honestly rather than letting the spinner run indefinitely.
- User cancellation - Treat differently from failure. Confirm the cancellation succeeded, state what was kept or discarded, and offer a clear restart path.
4.3 Recovery and Resumability
For any process that takes more than a few seconds, design for the assumption that something will go wrong - the network will drop, the tab will close, the laptop will sleep. The most resilient UIs persist process state to durable storage (IndexedDB, localStorage, or a server-side job record) so that on next load, the user can resume from where they left off.
function useResumableUpload(fileId) {
const [state, setState] = useState(() => {
const saved = localStorage.getItem(`upload:${fileId}`);
return saved ? JSON.parse(saved) : { offset: 0, status: 'idle' };
});
useEffect(() => {
localStorage.setItem(`upload:${fileId}`, JSON.stringify(state));
}, [fileId, state]);
async function resume(file) {
setState(s => ({ ...s, status: 'running' }));
for (let offset = state.offset; offset < file.size; offset += CHUNK_SIZE) {
const chunk = file.slice(offset, offset + CHUNK_SIZE);
await uploadChunk(fileId, offset, chunk);
setState({ offset: offset + CHUNK_SIZE, status: 'running' });
}
setState(s => ({ ...s, status: 'completed' }));
localStorage.removeItem(`upload:${fileId}`);
}
return { state, resume };
}
5. Building a Custom useLongRunningTask Hook
Repeating queue/run/cancel/recover logic in every component is unmaintainable. A custom hook centralizes the lifecycle, exposes a clean API to components, and ensures every long-running task in your app behaves consistently.
5.1 The useLongRunningTask Hook
import { useReducer, useRef, useCallback, useEffect } from 'react';
function useLongRunningTask(taskFn) {
const [state, dispatch] = useReducer(processReducer, initialState);
const controllerRef = useRef(null);
const start = useCallback(async (...args) => {
if (controllerRef.current) controllerRef.current.abort();
const controller = new AbortController();
controllerRef.current = controller;
dispatch({ type: 'queue' });
try {
dispatch({ type: 'start' });
const result = await taskFn(...args, {
signal: controller.signal,
onProgress: (value) => dispatch({ type: 'progress', value }),
});
if (!controller.signal.aborted) {
dispatch({ type: 'complete', result });
}
} catch (err) {
if (err.name === 'AbortError') {
dispatch({ type: 'cancel' });
} else {
dispatch({ type: 'fail', error: err.message });
}
}
}, [taskFn]);
const cancel = useCallback(() => {
controllerRef.current?.abort();
}, []);
const reset = useCallback(() => {
controllerRef.current?.abort();
dispatch({ type: 'reset' });
}, []);
useEffect(() => {
return () => controllerRef.current?.abort();
}, []);
return { ...state, start, cancel, reset };
}
// Usage
function VideoExport({ projectId }) {
const task = useLongRunningTask((id, opts) => api.exportVideo(id, opts));
if (task.status === 'idle')
return <button onClick={() => task.start(projectId)}>Export Video</button>;
if (task.status === 'queued' || task.status === 'running')
return (
<div>
<ProgressBar value={task.progress} />
<button onClick={task.cancel}>Cancel</button>
</div>
);
if (task.status === 'completed')
return <DownloadLink href={task.result.url} onDismiss={task.reset} />;
if (task.status === 'failed')
return <ErrorPanel message={task.error} onRetry={() => task.start(projectId)} />;
if (task.status === 'cancelled')
return <p>Export cancelled. <button onClick={task.reset}>Start over</button></p>;
}
5.2 Decoupling From the Component Lifecycle
A subtle but critical point: the hook above ties the task lifetime to the component that started it. If the user navigates away, the task is cancelled. For most long-running processes, that is the wrong default - users expect to start a job, leave the page, and find it still running when they return. The fix is to store the running task in a global state container (Zustand, Redux, or a React context provider above the router) so it survives navigation:
import { create } from 'zustand';
const useTaskStore = create((set, get) => ({
tasks: {}, // taskId -> task state
startTask(taskId, taskFn, args) {
const controller = new AbortController();
set(s => ({ tasks: { ...s.tasks, [taskId]: { status: 'running', progress: 0, controller } } }));
taskFn(...args, {
signal: controller.signal,
onProgress: (value) => set(s => ({
tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], progress: value } }
})),
})
.then(result => set(s => ({
tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], status: 'completed', result } }
})))
.catch(err => set(s => ({
tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], status: 'failed', error: err.message } }
})));
},
cancelTask(taskId) {
get().tasks[taskId]?.controller.abort();
},
}));
Now any component anywhere in the tree can read the task's current state, and the task continues to run regardless of which page is mounted.
Progress is impossible without change, and those who cannot communicate progress will be assumed to have made none. - Adapted from George Bernard Shaw
6. Advanced Techniques
6.1 Web Workers for CPU-Bound Tasks
If your long-running process is heavy computation in the browser - image processing, parsing a large CSV, encrypting a file - running it on the main thread will freeze the UI. A Web Worker moves the work to a background thread, keeping animations smooth and inputs responsive.
function useWorkerTask(workerUrl) {
const [progress, setProgress] = useState(0);
const [result, setResult] = useState(null);
const workerRef = useRef(null);
function start(payload) {
const worker = new Worker(workerUrl, { type: 'module' });
workerRef.current = worker;
worker.onmessage = (e) => {
if (e.data.type === 'progress') setProgress(e.data.value);
if (e.data.type === 'done') { setResult(e.data.result); worker.terminate(); }
};
worker.postMessage({ type: 'start', payload });
}
function cancel() {
workerRef.current?.terminate();
}
return { progress, result, start, cancel };
}
6.2 Server-Sent Events for Real-Time Progress
For long-running server-side jobs, polling for status is wasteful and laggy. Server-Sent Events (SSE) give you a one-way streaming connection that pushes progress updates as the server produces them - ideal for AI generation, video transcoding, or any workflow where the server is the source of truth.
function useJobStream(jobId) {
const [state, setState] = useState({ status: 'connecting', progress: 0 });
useEffect(() => {
const source = new EventSource(`/api/jobs/${jobId}/stream`);
source.addEventListener('progress', (e) => {
const data = JSON.parse(e.data);
setState({ status: 'running', progress: data.progress, stage: data.stage });
});
source.addEventListener('complete', (e) => {
setState({ status: 'completed', result: JSON.parse(e.data) });
source.close();
});
source.addEventListener('error', () => {
setState(s => ({ ...s, status: 'failed', error: 'Connection lost' }));
source.close();
});
return () => source.close();
}, [jobId]);
return state;
}
6.3 Browser Notifications and Tab-Title Updates
Users will not stare at a progress bar for ten minutes. They will switch tabs, check email, or walk away. To bring them back when the work is done, use the Notifications API for a native push and update the document title with a status icon so a glance at the tab strip tells them whether to come back.
function useCompletionNotification(status, message) {
useEffect(() => {
if (status === 'completed') {
document.title = '✓ Done — ' + originalTitle;
if (Notification.permission === 'granted') {
new Notification('Process complete', { body: message });
}
} else if (status === 'failed') {
document.title = '✗ Failed — ' + originalTitle;
} else if (status === 'running') {
document.title = '⏳ Working — ' + originalTitle;
} else {
document.title = originalTitle;
}
}, [status, message]);
}
Always request notification permission contextually - at the moment the user starts a long task, not on page load. Cold-start permission prompts are denied 90% of the time.
6.4 Persistent Background Jobs With Service Workers
For uploads, exports, or syncs that should survive even if the user closes the tab, register a Service Worker with the Background Sync API. The browser holds the work in a queue and resumes it when the network is available, even if the originating page is gone. This pattern powers the "your message will be sent when you're back online" experience in modern messaging apps.
async function queueBackgroundUpload(file) {
const reg = await navigator.serviceWorker.ready;
await reg.sync.register('upload-pending');
await stashFileInIndexedDB(file);
}
// In the service worker
self.addEventListener('sync', (event) => {
if (event.tag === 'upload-pending') {
event.waitUntil(processQueuedUploads());
}
});
7. Stats & Interesting Facts
- According to research compiled by the Nielsen Norman Group, the upper limit for keeping a user's attention focused on a dialog without progress feedback is approximately 10 seconds - past this, users start switching context and may abandon the task entirely. Source: https://www.nngroup.com/articles/response-times-3-important-limits/
- A study by Chris Harrison and colleagues at Carnegie Mellon University found that progress bars with backward-decelerating animation (fast at first, slowing down) are perceived as up to 12% faster than uniform progress bars covering the same actual time. Source: https://www.chrisharrison.net/index.php/Research/ProgressBars
- Akamai's 2017 online retail performance report found that a two-second delay during a checkout-related process more than doubled the bounce rate. Long-running processes without clear progress feedback amplify this effect dramatically.Source: https://www.akamai.com/newsroom/press-release/akamai-releases-spring-2017-state-of-online-retail-performance-report
- According to Cloudflare's State of Application Security report, large-file uploads abandoned mid-transfer due to lack of resumability cost e-commerce and SaaS platforms millions of dollars in lost user productivity each year.Source: https://www.cloudflare.com/lp/state-of-application-security-report/
- Chrome's Web Vitals data shows that pages with a Total Blocking Time over 600ms - common when long-running processes run on the main thread - have measurably higher abandonment rates than pages that offload work to Web Workers.Source: https://web.dev/articles/tbt
- A 2024 study published by the Baymard Institute found that 27% of e-commerce checkout abandonment is attributable to interfaces that appear unresponsive during payment processing - a textbook long-running process UX failure.Source: https://baymard.com/lists/cart-abandonment-rate
- Stripe's developer documentation reports that approximately 8% of payment-confirmation flows experience a network interruption between request and confirmation, making resumable, idempotent process handling essential for financial UX.Source: https://stripe.com/docs/payments/payment-intents
8. FAQ
1. When should a process be treated as "long-running" and given a dedicated UX, rather than just a spinner?
Ans: A useful heuristic is the three-second rule: any process that may take longer than three seconds in the 95th-percentile case deserves dedicated long-running UX. Below that threshold, a simple loading state is sufficient. Above it, you need progress feedback, cancellation, and survivability across navigation. Be honest about your real-world latency distribution, not the best case on a fast network.
2. Should a long-running task be tied to a single component, or should it survive navigation?
Ans: It depends on the user's mental model. If the task is conceptually local to the page (e.g., a search refinement), tying it to the component is fine. If the user thinks of the task as something they "started" - an upload, an export, a video render - it should survive navigation, ideally by living in a global state container or being driven by a server-side job that the UI subscribes to.
3. How do I show progress when the duration is genuinely unknown?
Ans: Show an indeterminate animation paired with descriptive status text that updates as the process advances ("Connecting...", "Analyzing...", "Generating output..."). This gives the user something to read and watch, signals real progress, and avoids the lie of a fake percentage. Never display a fabricated ETA - users notice when bars stall at 99% and lose trust.
4. What is the best way to cancel a long-running task that is partially complete on the server?
Ans: Always be explicit about what cancellation means in the UI. If the server can roll back the partial work, say so ("Cancel and discard"). If the partial work will be kept, say that instead ("Cancel — your progress so far will be saved"). Ambiguity here causes users to hesitate or, worse, to refresh the page mid-task and lose data.
5. How should I handle a tab being closed in the middle of a long-running process?
Ans: For client-driven processes, persist progress to IndexedDB or localStorage and offer to resume on next visit. For server-driven jobs, use a server-side job record so the work continues regardless of the client - the user can simply reconnect and the UI subscribes to the existing job. For uploads specifically, use a chunked, resumable protocol (tus.io is the open standard).
6. Should I use polling or streaming (SSE/WebSockets) for progress updates?
Ans: Streaming is strictly better for user experience: lower latency, less server load, no wasted requests. Use polling only when streaming infrastructure is unavailable, or as a fallback when the stream connection drops. If you do poll, use exponential backoff and a maximum interval to avoid hammering the server for jobs that take hours.
7. How do I keep the UI responsive during a CPU-heavy process in the browser?
Ans: Move the work to a Web Worker. The main thread is responsible for rendering and input handling - any long-running computation there will freeze the UI, drop frames, and make the page feel broken. Workers communicate via postMessage and have access to most APIs you need for compute-heavy tasks. For very large datasets, consider streaming results back in chunks rather than waiting for the entire computation to finish.
8. What's the right way to notify a user that a long-running task has finished if they've switched tabs?
Ans: Layer your notifications. First, update the document title with a status icon - users glance at tab strips constantly. Second, fire a Web Notification if permission has been granted. Third, for tasks that may complete after the user has fully closed the app, send an email or push notification from the backend. Never rely on a single channel; users check different channels at different times.
The most precious resource we all have is time. - Steve Jobs
9. Conclusion
Long-running processes are not edge cases - they are central to modern application UX. Every minute spent improving how your app handles uploads, exports, generations, and batch jobs returns disproportionate user trust. The strategies described in this article each address a specific dimension of that experience:
Explicit four-state modeling ensures queued, running, completed, and failed are always represented, never collapsed or implicit.
Progress communication - whether determinate, indeterminate, or multi-stage - turns waiting from a passive frustration into an informed wait.
Cancellation and recovery put the user in control, ensuring no process can trap them or destroy work silently.
Custom hooks and global stores decouple long-running tasks from the component lifecycle, so navigation and unmounts don't kill in-flight work.
Web Workers, SSE, and Service Workers keep the UI responsive, pipe real-time updates, and let work survive tab closures.
Notifications and title updates bring users back the moment their work is ready, instead of forcing them to babysit a progress bar.
The browser and modern frameworks give you every primitive you need. The responsibility lies with developers to compose them with care, treat long-running UX as a design problem rather than an engineering afterthought, and remember that for many users the difference between a finished task and an abandoned one is whether the interface respected their time.
The best long-running UX feels like a quiet promise kept. The user starts something, the system honors that intent through whatever conditions arise, and at the end - whether seconds or hours later - delivers the result clearly. That invisibility, that reliability, is the mark of mature product engineering.
About the Author: Abodh is a PHP and Laravel Developer at AddWeb Solution, skilled in MySQL, REST APIs, JavaScript, Git, and Docker for building robust web applications.
Top comments (0)