Give Users a Real Data Export
24 Jul 2026A “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
- The request enqueues a job and returns immediately.
- A worker streams the query results to a file, uploads it to object storage, and marks the job done.
- 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
- CSV for spreadsheets, JSON for developers. If your customers are non-technical, default to CSV — it opens in Excel/Sheets with zero explanation needed. Offer JSON alongside it if any of your users are technical enough to script against it.
- Signed URLs, not permanent public files. Exports contain a customer’s full dataset. A signed URL with a short expiry (an hour is plenty) means a leaked link doesn’t stay valid forever, and you’re not serving customer data from a public bucket path someone could guess.
- Rate limit the endpoint. One export request per tenant per few minutes is enough. Without it, someone’s dashboard integration polling the export button in a loop can pin your export queue and starve the customers actually waiting for a real download.
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.