# Watch a job until it ends

> Polling an export job until it finishes, then refreshing what it changed and downloading the file.

Page: https://svelte-lean.vercel.app/query/recipes/job-progress · Markdown: https://svelte-lean.vercel.app/query/recipes/job-progress.md · Index: https://svelte-lean.vercel.app/llms.txt

A server job polled until it finishes: the definition says how often and when to stop, invalidates what the job changed once, and the result downloads as a file.

## Example

Start an export and wait for it; the file downloads as `members.csv`.

```svelte
<!-- src/lib/members/ExportButton.svelte -->
<script lang="ts">
	import { createMutation, createQuery } from '@svelte-lean/query';
	import { downloadExport, getJob, startExport } from '$api/jobs';

	let jobId = $state<string | null>(null);

	const start = createMutation(startExport, {
		onSuccess: (job) => {
			getJob.set({ id: job.id }, job); // seed the entry: the end is a transition even if fast
			jobId = job.id;
		}
	});
	// getJob polls every 2 s until the job ends, then invalidates 'Exports' once.
	const job = createQuery(getJob, () => (jobId ? { id: jobId } : undefined));

	async function download(id: string) {
		const file = await downloadExport.fetch({ id });
		const url = URL.createObjectURL(file.blob);
		const link = Object.assign(document.createElement('a'), {
			href: url,
			download: file.filename ?? 'export.csv'
		});
		link.click();
		URL.revokeObjectURL(url);
	}
</script>

<button
	type="button"
	disabled={start.busy || job.data?.state === 'running'}
	onclick={() => start.run({}).catch(() => {})}
>
	Export members
</button>

{#if start.error}<p role="alert">{start.error.message}</p>{/if}
{#if job.data}
	<label>
		{job.data.state === 'done' ? 'Ready' : 'Exporting'}
		<progress max="100" value={job.data.progress}></progress>
	</label>
	{#if job.data.state === 'done'}
		<button type="button" onclick={() => download(job.data!.id)}>Download {job.data.file}</button>
	{/if}
{/if}
```

The component shown, against an in-memory server whose job advances a quarter on every read.

`src/lib/api/jobs.ts`

```ts
import { defineMutation, defineQuery, file, tag, type } from '@svelte-lean/query';
import type { Job, MemberFilters } from './types';

declare module '@svelte-lean/query' {
	interface Tags {
		Job: string;
		Exports: void;
	}
}

export const startExport = defineMutation({
	post: 'api/exports',
	body: type<MemberFilters>(),
	result: type<Job>()
});

export const listExports = defineQuery({
	get: 'api/exports',
	result: type<Job[]>(),
	tags: ['Exports']
});

export const getJob = defineQuery({
	get: 'api/jobs/{id}',
	result: type<Job>(),
	tags: (job) => [tag('Job', job.id)],
	// Poll while the job runs; on the commit that finishes it, stop and invalidate once.
	poll: {
		every: '2s',
		until: (job) => job.state === 'done' || job.state === 'failed',
		invalidates: ['Exports']
	}
});

export const downloadExport = defineQuery({
	get: 'api/jobs/{id}/file',
	result: file() // a FileResult: blob, filename from Content-Disposition, type
});
```

## How it works

| Moment | Request |
| --- | --- |
| Export | `POST api/exports`; `onSuccess` seeds the job entry with the answer |
| While the job runs | `GET api/jobs/export-1` on a fixed grid, cause `poll`, shown as `refreshing` |
| Tab hidden | no poll; one request when the tab is visible again |
| The commit where `until` holds | polling stops; `invalidates: ['Exports']` runs once |
| Download | `GET api/jobs/export-1/file`; the answer is a `FileResult` |

- `invalidates` runs on the transition to done only: a page that mounts a finished job invalidates nothing. Seeding the entry with the start answer makes a job that ends before the first poll a transition too.
- Polling belongs to the definition, so every screen that shows the job polls it the same way; a screen can add its own `poll`, and the shortest active interval wins.
- `file()` makes `http()` read the body as a blob and the file name from `Content-Disposition`.

## Variations

A period that depends on the value: `poll: (batch) => (batch.active ? '3s' : false)` on the definition. Polling while the tab is hidden: `background: true` in the object form; browsers throttle hidden timers, so freshness that matters in the background belongs to a [realtime](https://svelte-lean.vercel.app/query/realtime.md) source.

## Related

- [Reading](https://svelte-lean.vercel.app/query/reading.md): polling per use.
- [./http](https://svelte-lean.vercel.app/query/api/http.md): uploads with progress and downloads.
