26 August 2026
How I improved CSV export architecture in enterprise
A synchronous streaming export worked fine at small scale. At a million rows, it started timing out. Here is how I redesigned it, and the gotchas along the way.
When I joined the team, the CSV export feature worked by streaming data synchronously directly back to the HTTP response. The browser would download the file straight from the API response using a
Content-Disposition: attachmentheader. For the data volumes at the time, it was a reasonable approach.As the product scaled past a million orders in a single month, timeout errors started hitting production. The synchronous approach had a ceiling, and we had reached it.
What the old architecture looked like
The original flow was straightforward:
The synchronous streaming approach
The user hit the export endpoint. The server streamed all pages from the database, building the CSV on the fly. The HTTP response was the CSV file itself, downloaded directly by the browser.
This works well at small data volumes. The problem is that it ties the entire export operation to a single HTTP connection. Cloud Run defaults to a 60 second timeout, and most API gateways enforce their own limit on top. For exports with over a million rows, the stream simply could not complete within that window.
There was no retry, no recovery, and no way for the user to know whether the export had failed or was still running. The connection would drop and the browser would show an error.
Moving to an async approach
The fix was to decouple the export operation from the HTTP response entirely. The endpoint now returns immediately, the export runs in the background, and the user gets notified when the file is ready.
Return 202 immediately, run the export in the background
The user hits the export endpoint. The server responds immediately with a 202 Accepted and a message telling the user the export has started and to check their messenger. The export then runs as a background job, writing the CSV to cloud storage. When it completes, a notification is posted with a signed download URL.
This removes the timeout problem entirely. The HTTP connection closes in milliseconds. The export can take as long as it needs. The user does not have to keep a browser tab open or watch a loading spinner.
What caught me out during implementation
Moving to async exports introduced three problems that were not obvious upfront.
Silent stream failures: errors that looked like success
Node.js Writable streams emit errors as events via stream.on('error', ...), not as thrown exceptions. A try/catch block around the stream write will never catch them.
In practice this meant the export would appear to succeed, no file would be written to storage, and no notification would arrive. The job completed without error from the server's perspective, but the user got nothing.
The fix had three parts. First, attaching an error event listener to capture stream errors into a shared streamError variable. Second, polling streamError at every checkpoint: after the initial fetch, after writing headers, and after each page batch. Third, implementing proper backpressure handling. writeStream.write() returns false when the internal buffer is full. Ignoring that return value causes data loss and memory pressure. The fix was awaiting the drain event before continuing to write.
let streamError: Error | null = null;
writeStream.on("error", (err) => {
streamError = err;
});
const writeChunk = (chunk: string): Promise<void> => {
return new Promise((resolve, reject) => {
const ok = writeStream.write(chunk);
if (streamError) return reject(streamError);
if (ok) return resolve();
writeStream.once("drain", () => {
if (streamError) return reject(streamError);
resolve();
});
});
};
Messenger as the delivery mechanism
The notification mechanism for delivering the download link was a deliberate choice. Email adds latency and often lands in filtered folders. A polling endpoint would require the client to implement retry logic and manage state. The team was already using an internal messenger for operational alerts, and the export feature was for internal users, making it the natural place to drop the signed URL when the file was ready.
The signed URL approach also means the file in storage is never publicly accessible. The URL is time-limited and tied to the specific export. Once it expires, the file is unreachable without generating a new one.
Firestore offset pagination hits a hard limit at scale
The original pagination used Firestore's offset() to page through results. The problem with offset() is that Firestore scans and skips documents to reach the offset position. At page 50 with a page size of 1000, that is offset(50000). Firestore's hard limit on offset is exactly that boundary, and beyond it the query either errors or returns nothing.
The fix was switching to startAfter() cursor pagination. Instead of telling Firestore to skip N documents, you pass it the last document from the previous page as a cursor. Firestore uses an index position rather than a skip count, so there is no row limit. The query is also faster at every page because it does not need to scan and discard documents to find the starting point.
// Before: hits a hard limit at large offsets
const snapshot = await collection
.orderBy("createdAt")
.offset(page * pageSize)
.limit(pageSize)
.get();
// After: no row limit, faster at every page
const snapshot = await collection
.orderBy("createdAt")
.startAfter(lastDocument)
.limit(pageSize)
.get();
Closing
Final thoughts
The synchronous streaming approach was not wrong when it was built. It matched the data volume at the time and kept the implementation simple. The problem emerged when the product scaled past the point where a single HTTP connection could hold an entire export operation.
The async redesign solved the timeout problem, but the three gotchas were where most of the implementation work actually lived. Silent stream failures are particularly dangerous because they produce no visible error. The job finishes, the logs look clean, and the user simply never receives their file. Treating stream error events as first-class failures, with explicit polling at every checkpoint, is the pattern I would apply from the start on any future stream-to-storage job.
The Firestore pagination issue is worth remembering as a general principle: offset-based pagination has a cost that grows linearly with page number, and most databases impose a hard limit somewhere. Cursor-based pagination does not have that ceiling and is faster at every page. For any export job that may need to paginate through large datasets, cursor pagination is the only approach worth reaching for.