(updated)
|
min. read

Building Offline-First File Uploads with PowerSync Attachments Helper

Emil Zulufov (Guest author)

PowerSync is an amazing tool to create completely offline-first applications with amazing user experience. There is one thing that has always held me back - attachments (photos/videos). This was missing part in my apps, it was the only thing that kept me apart from practically calling my app 100% offline. I’ve finally solved it once and for all, and I’m here to tell you the story.

It is very common to see some sort of attachments uploading in a vast majority of mobile/web applications. For example, Instagram posts photos/videos uploading is a fundamental feature, and it is crucial to keep the uploading flow smooth, uninterrupted and continuous. This is where PowerSync takes advantage, providing a very convenient, easy-to-use attachments helper to get this job done with straightforward API and integration.

What is PowerSync?

PowerSync is a powerful synchronisation layer that makes apps feel fast, reliable, and always connected. It’s built to help developers turn basic cloud-connected apps into truly offline-first experiences, where everything keeps working smoothly.

At its core, PowerSync keeps your local app data and backend server data perfectly in sync. When you make changes offline, they’re stored locally first and automatically sync up once your internet connection returns.

What to note:

PowerSync feels instant because all actions happen locally, so users never wait on a network call. It’s always reliable since data safely syncs later, ensuring nothing gets lost. The system requires low maintenance as you focus on your app logic while PowerSync handles the complexity of sync and conflict resolution. Finally, it’s built for scale and works just as well for small apps as for large systems handling thousands of updates.

PowerSync guarantees Causal+ Consistency while allowing partial replication and offline work, handling the tricky parts of network retries, browser storage, and cross-tab sync. This makes it a great tool for offline-capable apps where you want to remain in control of your backend.

The PowerSync Service specifically syncs rows, filtered by sync rules that you define.

That foundation makes advanced features like attachments uploading much easier and more resilient to build.

PowerSync’s attachments helper gives you a “Telegram‑style” send‑and‑forget layer for files: you enqueue uploads locally, and the helper takes care of syncing, retrying, and cleaning up between device storage and your backend.

The Challenge

If you’re in a hurry, you can skip to the demo repo.

Before diving into the implementation, let me share what I was trying to achieve. I wanted to replicate that Telegram/WhatsApp-style experience where you send a message with photos or videos and don’t have to worry about your internet connection, it just uploads eventually.

On top of that, I needed the ability to safely close the app mid-upload without losing progress. Users should be able to start uploading a post with multiple photos, close the app, and come back later to find everything still uploading or already completed.

Breaking this down, there are four core challenges to solve:

  1. Remote adapter - Upload attachment bytes to your storage backend (Supabase, S3, etc.)
  2. Continuous background sync - Automatically retry failed uploads, handle network interruptions
  3. Persistent queue - Store upload state so nothing is lost if the app closes
  4. Local preview - Show posts and attachments with upload progress before they’re synced to the server

PowerSync’s attachments helper handles challenges 1, 2, and 3 out of the box. You just need to implement a %%RemoteStorage%% interface for your backend, and PowerSync takes care of the queue management, retries, and persistence. %%Local Preview%% is application-specific but follows a common pattern I’ll show you.

When to Use the Attachments Helper

The attachments helper shines when persistence and reliability truly matter. Here’s a quick guide:

Use it when:

  • Uploading multiple attachments per post/message
  • Offline-first behaviour is critical
  • Users need to see upload progress
  • You want “send and forget” behaviour

You can skip it when:

  • Uploads are not frequent and quick (like profile avatars)
  • Persistence isn’t a concern
  • You’re okay with blocking UI during uploads

For this demo, I’m building a social media-style app where posts can have multiple photos. Users need to see their posts immediately with upload progress, and everything must persist across app restarts.

How the Attachments Helper Works

Relevant code:

At its core, PowerSync’s attachments helper provides an %%AttachmentQueue%% that manages file uploads. You enqueue files with metadata, and the queue handles the rest: uploading to your backend, tracking progress, retrying on failure, and persisting state.

The queue stores attachments in a special %%attachments_queue%% table (automatically created by PowerSync) that tracks each file’s state: preparing, uploading, completed, or failed. This table is local-only, meaning it never syncs to your backend.

To use it, you implement the %%RemoteStorage%% interface with three methods:

  • %%uploadFile%% - Stream file data to your storage
  • %%downloadFile%% - Retrieve files (if needed)
  • %%deleteFile%% - Remove files from storage

The helper calls these methods as needed, and you control retry behavior through an %%AttachmentErrorHandler%% that returns %%true%% to retry or %%false%% to give up on specific errors.

Building the Demo

This demo app (source code) implements a complete post creation flow with photo attachments.

Setting Up the Schema

Relevant code: AttachmentsQueueTable

First, we define our database schema. The key pieces are the %%AttachmentsQueueTable%% and local-only tables for posts and attachments:

AttachmentsQueueTable(
  additionalColumns: const [
    Column.text('post_id'),
    Column.integer('sent'), // bytes uploaded so far
  ],
),
Table.localOnly('posts_local', [...]),
Table.localOnly('post_attachments_local', [
  // ... attachment fields plus 'sent' for progress
]),

The local-only tables %%posts_local%% and %%post_attachments_local%% are crucial, they let us show posts immediately while attachments upload, without syncing incomplete posts to other users. Once all attachments finish uploading, we move the post to the regular %%posts%% table, which PowerSync syncs to the backend.

I should note that this approach of using separate local tables is my personal preference. You could also use a simpler pattern with just reference columns in synced tables. For me, it felt more natural to treat the %%attachments_queue%% table as an internal implementation detail that the attachments helper manages internally, handling uploads, deletions, and downloads. For display and business logic purposes, having dedicated tables felt like a cleaner separation of concerns. The attachments helper manages the core business logic with all background workers and internal sync loops, while the display layer can be adjusted based on your own preferences and needs.

You can see the full schema here.

Initialising the Queue

When the app starts, we initialise the attachment queue with our %%RemoteStorage%% implementation:

final remoteStorage = SupabasePostStorageAdapter(
  db: _db,
  uploadedAttachmentsStorage: uploadedAttachmentsStorage,
);

final attachmentQueue = await initializePostAttachmentQueue(
  _db,
  remoteStorage,
);

await attachmentQueue.startSync();

Relevant code: SyncingService.startSync

The initialisation code is in powersync_client.dart. The %%startSync()%% call begins processing the queue in the background, automatically retrying failed uploads and handling new ones as they’re added.

You might notice that %%watchAttachments%% returns %%Stream.value([])%% in the initialisation. This is because in this demo, all images are loaded on-demand in the image widget itself when posts and images are displayed, so there's no need to watch remote tables and automatically download attachments. This approach bypasses the library's automatic orphan cleanup feature, which would normally delete local files that aren't referenced in the stream. If you ever need to handle stale file cleanup manually, you'd need to implement that yourself, but for this use case where files are managed explicitly through the upload queue, it hasn't been necessary.

Creating Posts with Attachments

When a user creates a post, the flow depends on whether there are attachments:

final postTable = attachments.isEmpty ? 'posts' : 'posts_local';

If there are no attachments, we write directly to %%posts%% and PowerSync syncs it immediately. If there are attachments, we write to %%posts_local%% and start uploading each attachment to the queue.

For each attachment, we call saveAttachment, which adds it to the queue:

await _powerSyncClient.saveAttachment(
  data: File(file.path!).openRead(),
  file: file,
  postId: id,
  attachmentId: attachment.imageUrl?.removeExtension,
  isUploaded: attachment.uploadState.isSuccess,
  minithumbnail: minithumbnail,
);


This code is in powersync_database_client.dart. The %%saveAttachment%% method wraps PowerSync’s %%attachmentQueue().saveFile()%%, which stores the file locally and adds it to the queue for upload.

The Upload Pipeline

Once an attachment is in the queue, PowerSync calls our %%RemoteStorage.uploadFile%% method. This is where all the business login lies in supabase_storage_adapter.dart.

The upload process begins by validating that the attachment exists, checking that the local attachment row still exists since it might have been deleted. Next, we upload to Supabase Storage by streaming the file bytes to Supabase and reporting progress via %%onSendProgress%%. As the upload progresses, we track progress by updating the %%post_attachments_local.sent%% column with bytes uploaded so far. After each successful upload, we check completion by counting remaining attachments. Finally, when all attachments are uploaded, we publish the post by moving it from %%posts_local%% to %%posts%%.

The progress tracking happens here:

onSendProgress: (count, total) async {
  await _db.execute(
    '''
      UPDATE post_attachments_local
      SET sent = ?, file_size = ? WHERE image_url = ? AND post_id = ?
    ''',
    [count, total, storageFileName, postId],
  );
},

This updates the local table, which the UI watches to display progress. The PostAttachment widget reads the %%sent%% and %%file_size%% columns to show a percentage indicator.

In the implementation you might notice I explicitly use %%Dio%% to upload bytes to the Supabase, rather than Supabase API directly. This is because Supabase doesn't provide %%onSendProgress%% callback, so you can't dispatch bytes uploaded so far for granular progress indicator. There is a %%supabase_progress_uploads%% library, I've been playing a lot with, and while it provides progress callback and has a bunch of cool features out of the box, it has a significant drawback - it slows down the uploading process by about 3-5 times. Internally it uses %%tus_client_dart%%, which is a network client that uses TUS protocol for resumable uploading, that causes this “lag”. While pausing/resuming might be beneficial, the trade-off with the speed is not acceptable.

Ensuring Atomic Post Publishing

One of the trickiest parts is ensuring a post only appears to other users after all its attachments are uploaded. We solve this with a completion check after each upload:

var leftAttachments =
    (existingLocalAttachmentsCount) - uploadedAttachments.length;

// Account for deleted attachments
leftAttachments -= deletedAttachments.length;

if (leftAttachments == 0) {
  await _uploadPost(
    tx: tx,
    postId: postId,
    localPostRow: localPostRow,
    uploadedAttachments: uploadedAttachments,
  );
}

The %%_uploadPost%% method (implementation) atomically deletes from %%posts_local%%, inserts into posts (which PowerSync syncs to backend), inserts attachments into %%post_attachments%% (synced to backend), and cleans up %%post_attachments_local%%.

Handling Errors and Retries

The attachments helper includes an error handler that decides whether to retry failed uploads:

errorHandler: AttachmentErrorHandler(
  onUploadError: (attachment, exception, stackTrace) async {
    if (exception is UploadFileNotFoundFailure ||
        exception is UploadPostNotFoundFailure) {
      return false; // Don't retry - file/post was deleted
    }
    return true; // Retry on other errors
  },
),

This is configured in attachments_queue.dart. Returning %%false%% tells PowerSync to stop retrying (useful when a post is deleted mid-upload). Returning %%true%% triggers automatic retries.

Persistence Across App Restarts

Here’s where PowerSync really shines, everything persists automatically. The %%attachments_queue%% table is stored in SQLite, so if the app closes mid-upload, the queue state is preserved. When the app reopens and calls %%attachmentQueue.startSync()%%, PowerSync resumes uploading from where it left off.

The local-only tables (%%posts_local%%, %%post_attachments_local%%) also persist, so users see their in-progress posts immediately when reopening the app. The business logic in user_profile_bloc.dart queries and watches both local and synced posts, creating a seamless experience where users see their own posts immediately.

The Result

With this implementation, users get a truly offline-first experience. Posts appear immediately with upload progress, providing instant feedback to users. The system survives app restarts seamlessly, with everything continuing to upload after reopening the application. Posts are published atomically, meaning they only appear to other users when fully uploaded. The implementation includes graceful error handling where failed uploads retry automatically. Users can track progress in real-time with percentage indicators shown for each attachment.

The attachments helper handles all the hard parts: queue management, retries, persistence, and background sync. You just implement the storage adapter and define your local-only tables for preview.

Try It Yourself

You can explore the complete implementation in the demo repository. The key files to check out:

The attachments helper transformed what would have been weeks of work (background uploads, retry logic, persistence, progress tracking) into a straightforward integration. If you’re building an app with file uploads, especially multiple files per entity, I highly recommend giving it a try. But, it's worth a note that attachments API is experimental and can be changed significantly at any time, consider using it at your own risk. From my side, I'll do my best to keep up with the most up-to-date solution in this demo app. Happy coding!