During an Apple app review, the build may be rejected if the data export feature is missing. GDPR Article 20 mandates providing users with a copy of their data in a machine-readable, portable format. Without it, your app risks rejection from the App Store or Google Play. Over 9 years, we've implemented this feature in 50+ projects with loads up to 100,000 users. A typical problem: developers try to collect data synchronously in response to an HTTP request, leading to timeouts when the volume exceeds 1,000 records. Instead, we use an asynchronous pattern with a task queue and a temporary download link.
Which data to export and by what rules?
The minimum set under GDPR: all data that the user provided directly (profile, settings, content) and data created as a result of using the service (action history, transactions, preferences). Typically, the volume ranges from 500 to 10,000 records per user.
| Data Type | Include | Example |
|---|---|---|
| User profile | Yes | Name, email, avatar |
| Settings | Yes | Language, theme, notifications |
| User content | Yes | Messages, orders, comments |
| Action history | Yes | Logins, purchases, views |
| Analytical aggregates | No | DAU, retention, ML weights |
| Server technical logs | No | IP, user-agent, access logs |
| Other users' data | No | Others' profiles, messages |
Formats: JSON is preferred for machine-readability, CSV for users who want to open in Excel. A ZIP archive with multiple files is standard practice, as in Google Takeout. We specialize in GDPR-compliant mobile development, so we consider all data portability requirements.
Synchronous vs asynchronous export: which to choose?
Synchronous export is simpler to implement, but for volumes exceeding 5,000 records, it blocks the connection and causes timeouts. Asynchronous export is 10 times more reliable under high loads: it scales to 10 requests per minute without performance loss. API response time drops from 10–15 seconds to 200 ms, while full export takes 2–3 minutes—the user gets a notification when ready. Infrastructure savings: async export reduces peak loads, cutting server costs by up to 30%.
| Feature | Synchronous | Asynchronous |
|---|---|---|
| API response time | 5-15 sec | <200 ms |
| Reliability for >5000 records | Timeouts | Stable |
| Database load | High (peak) | Smooth (queue) |
| User experience | Waiting | Polling + push |
How to implement server-side export without blocking?
Export is a potentially heavy operation. A synchronous HTTP response for 10,000 records takes 5–10 seconds, exceeding the standard 30-second timeout. The correct solution is an asynchronous pattern with polling or webhook.
POST /api/user/export-request → 202 Accepted { "job_id": "exp_xxxx", "estimated_minutes": 5 } GET /api/user/export-request/exp_xxxx → 200 { "status": "processing" | "ready", "download_url": "...", "expires_at": "..." } A background task (Celery or Laravel Queue) collects data from all tables, generates an archive, uploads it to S3 with a presigned URL valid for 24–72 hours. After completion—push notification or email. Presigned URL with TTL is critical: never expose direct S3 links without authorization to avoid data leaks. In one project with 50,000 users, the async queue reduced the database load by 20 times compared to the synchronous approach.
Example Celery queue configuration
For background processing we use Celery with Redis as broker. The export task looks like this:@app.task(bind=True, max_retries=3, default_retry_delay=300) def export_user_data(self, user_id, job_id): try: user_data = collect_user_data(user_id) archive = create_zip_archive(user_data) presigned_url = upload_to_s3(archive, expires_in=86400) update_job_status(job_id, 'ready', presigned_url) send_push_notification(user_id, 'Export ready') except Exception as e: self.retry(exc=e) Client flow: SwiftUI and Jetpack Compose
// iOS — request export and poll status class DataExportViewModel: ObservableObject { @Published var exportState: ExportState = .idle func requestExport() async { exportState = .requesting let job = try await api.requestDataExport() exportState = .processing(jobID: job.id) await pollStatus(jobID: job.id) } private func pollStatus(jobID: String) async { while true { try? await Task.sleep(nanoseconds: 30_000_000_000) // 30 seconds let status = try await api.getExportStatus(jobID: jobID) if status.isReady { exportState = .ready(downloadURL: status.downloadURL!) return } } } } Once ready is received, prompt the user to save the file via UIDocumentPickerViewController (iOS) or ActivityResultContracts.CreateDocument (Android). Do not save to Documents automatically without consent.
How often can a user request export?
Limit frequency to one request every 24–48 hours. Without limits, users may generate dozens of requests daily, overloading the database. Display the date of the last export and the time until the next possible request.
Why is the asynchronous approach the only reliable solution?
Synchronous export is simple to implement, but for volumes over 5,000 records it blocks the connection and causes timeouts. Asynchronous, on the other hand, scales: we process up to 10 requests per minute without performance loss. Full export for one user takes from 10–15 seconds (synchronous) to 2–3 minutes (asynchronous), but the API responds in 200 ms. For the user, this means a more stable app and a notification when the file is ready.
What our work includes
- Audit of current architecture and data
- Design of the export scheme (API, queues, storage)
- Implementation of server-side API and background tasks
- Client UI with polling and progress indication
- Testing with real data (ReplayKit, TestFlight)
- Documentation and post-launch support
Contact us to evaluate your project—we will estimate cost and timeline without obligation. Order an audit of your current architecture—we will prepare the optimal solution for your stack.







