24 Sept 2026 · 10 min read

How we fixed large exports

As the platform scaled, synchronous CSV exports went from quick to unusable. Here is how we moved them to background jobs, and why Temporal did the heavy lifting.

Alex
SaaS team
The short version
  • Synchronous exports worked at launch scale; as data volumes grew they timed out and dragged the dashboard API down with them.
  • We applied the standard SaaS answer, acknowledge, queue, notify: return a job ID immediately and let workers do the heavy lifting.
  • Temporal, already in our stack, covered the hard parts of that pattern out of the box, so we built on it instead of hand-rolling retries, heartbeats and deduplication.

The problem we had

When we first built CSV exports into the dashboard, they were a quick HTTP request. The API service would query the database, format the rows, and stream the file back. The process only took a few seconds. As the platform grew, tenants that used to export hundreds of rows were now exporting hundreds of thousands, still powered by the same code.

Users started to see a loading spinner for several minutes without any indication of progress, while the queries and formatting burned CPU on the same dashboard that powered everyone else's screens. Even worse, for large enough data, the request simply timed out, wasting every row of the computed work. Frustrated users who were faced with a dead spinner long enough assumed the export failed and clicked again. Every retry was another full export adding load to an already degraded API. Ironically, the feature broke first for the tenants who had the most to export.

How most companies solve it

Managing long-running tasks is a well-known hurdle for SaaS products. The textbook answer for this problem is to stop handling exports inside the request. Instead, we split the work into three phases:

  • Acknowledge - Validate the request, dedupe on an idempotency key, create a Job in the database, and respond with a Job ID. The UI displays a toast message that the request is worked on in the background.
  • Process - Register the job on a durable queue. Workers pull jobs at their own pace: run the export query, stream the rows into a CSV, and upload the file to the storage bucket.
  • Notify - Mark the job as completed and e-mail the user a link to the export file.

This split ensures the API service and the workers scale independently. The workers autoscale on queue depth, and a worker crash is retried by another worker instead of impacting the user. Repeat clicks are cheap too: instead of kicking off another full export, the API sees one is already running and returns the existing job.

How we leveraged Temporal

While comparing technologies to build the solution, we realised Temporal, which we were already running in our stack, could do most of the heavy lifting for us. Temporal retries failed workflows by design, ships with a durable task queue, and detects dead workers through heartbeats. Each workflow also keeps its full execution history, so a failed export comes with the evidence needed to debug it. The export itself runs as a single activity: a failed attempt is retried automatically under the activity's retry policy, starting again from the beginning.

What the user gets now

Requesting an export now returns an immediate confirmation while the work runs in the background, and the dashboard polls the job's status. When the file is ready, a notification offers the download through a short-lived signed storage link, and a failed export is retried automatically. The tenants with the largest data volumes, who previously could not complete an export at all, now get their files reliably, and the dashboard stays responsive for everyone else while an export runs.

Background jobsExportsQueuesTemporal