IAnswerable

Give Users a Real Data Export

24 Jul 2026

A “download your data” button that runs a synchronous query and streams a response works fine in the demo and times out in production the first time a real customer with 200,000 rows clicks it. Build exports as a background job from the start — it’s not much more code, and it’s the difference between a feature and a support ticket generator.

Why it’s worth building well

Data export is a trust signal disproportionate to its engineering cost. Customers evaluating a B2B tool ask “can I get my data out” before they ask about most features, because they’ve been burned by vendor lock-in before. A export that actually works, on the first try, on a large account, does more for a sales conversation than most feature work.

Don’t do it synchronously

The naive version:

app.get('/export', async (req, res) => {
  const rows = await db.query('SELECT * FROM orders WHERE tenant_id = $1', [tenantId])
  res.send(toCsv(rows))
})

This works until the query takes longer than your load balancer’s timeout (commonly 30-60 seconds), or the row count is large enough that building the whole CSV in memory before sending it blows your process’s memory limit. Both failure modes look identical to the user: the button spins forever, then errors.

Build it as a job

  1. The request enqueues a job and returns immediately.
  2. A worker streams the query results to a file, uploads it to object storage, and marks the job done.
  3. The user gets a notification (in-app, email, or both) with a link when it’s ready.

That flow crosses several components, which is where a numbered list stops being enough:

sequenceDiagram
    participant Client
    participant API
    participant Queue
    participant Worker
    participant Storage as Object Storage
    Client->>API: POST /export
    API->>Queue: enqueue job
    API-->>Client: 202 with jobId
    Queue->>Worker: deliver job
    Worker->>Storage: stream and upload CSV
    Worker->>Client: email with signed URL
  
app.post('/export', async (req, res) => {
  const job = await queue.add('export', { tenantId: req.tenant.id, userId: req.user.id })
  res.json({ jobId: job.id })
})

// worker
async function runExport({ tenantId, userId }) {
  const stream = db.query(new Cursor('SELECT * FROM orders WHERE tenant_id = $1', [tenantId]))
  const upload = s3.upload({ Bucket: 'exports', Key: `${tenantId}/${Date.now()}.csv` })
  await pipeline(stream, csvStringify(), upload.createWriteStream())
  const url = await s3.getSignedUrl('getObject', { Bucket: 'exports', Key: upload.key, Expires: 3600 })
  await sendEmail(userId, 'Your export is ready', url)
}

Streaming the query with a cursor instead of loading all rows into memory is what lets this scale past whatever fits comfortably in RAM — a 2 million row export shouldn’t behave differently from a 2,000 row one, just slower.

Format and access

The PII gotcha

If your data includes other users’ PII — a shared workspace, a CRM with contact info — decide explicitly what an export includes before you ship it. “Export everything visible to me” can mean exporting emails and phone numbers of people who never agreed to be in that export. Scope the query to what that specific user’s role should be able to see, the same way you’d scope any other read — export bypassing your normal authorization checks is an easy way to turn a trust-building feature into a data leak.