Skip to content
ALI HAITHAM·TECH·responds in <1h
  • Homestart here
  • Workcase studies, projects
  • Serviceswhat I will build for you
  • Writingessays, series
  • Traincourses, labs, cohorts
  • Aboutthe engineer behind this site
Sign inStart a project
ALI HAITHAM · TECH
  • Home↗
  • Work↗
  • Services↗
  • Writing↗
  • Train↗
  • About↗
Sign inStart a project
Online
ALI HAITHAM·TECH

Engineering studio. Damascus, GMT+3.

aliyosef.online

Studio

  • Work
  • Writing
  • Training
  • About
  • Contact me

Portal

  • Sign in
  • Open a ticket
  • Track project
  • My account

Resources

  • Docs
  • Status
  • Changelog
  • Brand kit
  • Privacy
  • Terms

Newsletter

Field notes and tech news. Weekly. No fluff.

Free. Unsubscribe anytime.

Find me elsewhere
© 2026 Ali Haitham Yosef. All rights reserved.Hand-built in React 19. No frameworks of frameworks.Last deployed · 2026-05-08All systems operational
COURSESync engines, end-to-end↗

Also relevant in:Offline-first Flutter

  1. Home/
  2. Writing/
  3. Sync engines
Sync engines

Building a sync engine, part one: the queue

Series opener. The shape of an outgoing queue, why ordering matters, and the test harness that catches everything.

April 20, 2026·14 min·by Ali Yosef
Part ofsync-engine→

Loading essay…

ShareXLinkedInRSS
In this seriesPart 2 of 2
←PreviousBuilding a sync engine, part two: orderingSeries overview→
Get the next essay in your inbox.New posts every Sunday morning. Engineering, in plain language. Free, unsubscribe anytime.Subscribe→
AY
Ali Haitham Yosef

Engineer and writer. Builder of multi-tenant platforms, sync engines, and quietly reliable systems. 600+ shipped projects since 2015.

SubscribeStart a project→

Read next

  • S2
    Series
    Nov 28, 2025·Sync engines

    Building a sync engine, part two: ordering

    Vector clocks for the offline reality. Why they pay off, and the simpler model that does not.

    13 min
  • PB
    May 5, 2026·Sync engines

    When polling beats websockets

    A year of trade-offs in Mohaseb. Why the obvious answer was wrong on our network conditions.

    8 min
  • CR
    Apr 28, 2026·Sync engines

    Conflict resolution in offline-first apps

    Last-write-wins is almost always wrong. Here is what works in practice when two clients disagree.

    10 min
←Back to all essays

A sync engine is a small queue, two endpoints, and a lot of edge cases. The queue is where most engines fail in their first month, because the obvious implementation works perfectly for the first hundred users and then quietly loses data. This is part one of five on the engine that keeps a Flutter client and a Laravel backend honest. We start with the queue because every other piece of the engine assumes it works.

What the queue actually has to do#

Every offline-capable write the user makes (creating an invoice, editing a contact, deleting a row) lands in a local table on the device first, then joins a queue waiting to be pushed to the server. The queue must:

  1. Persist across app restarts. A user who creates an invoice in the subway and force-quits the app has not made a contract with us about when their invoice should sync. It must sync.
  2. Be order-preserving for related writes. If the user creates a contact, then creates an invoice for that contact, the contact has to land on the server first. Otherwise the invoice arrives orphaned.
  3. Be idempotent. A retry must not produce a duplicate row. The network will retry. The user will retry. The OS will retry.
  4. Be observable. When something gets stuck, you need to look at the queue and understand why without guessing.

If your queue does not do all four, you do not have a queue. You have an optimistic write and a prayer.

The shape that survives#

After three rewrites we landed on this row shape, in a Drift table on the client:

@DataClassName('SyncQueueItem')
class SyncQueue extends Table {
  TextColumn get id => text()();                       // local UUID
  TextColumn get entityType => text()();               // 'invoice', 'contact'
  TextColumn get localEntityId => text()();            // the row this writes
  TextColumn get operation => text()();                // 'create' | 'update' | 'delete'
  TextColumn get payload => text()();                  // JSON, the actual diff
  TextColumn get idempotencyKey => text().unique()();  // server contract
  IntColumn  get attempts => integer().withDefault(const Constant(0))();
  TextColumn get status => text()                       // 'pending' | 'in_progress' | 'failed' | 'synced'
    .withDefault(const Constant('pending'))();
  DateTimeColumn get createdAt => dateTime()();
  DateTimeColumn get nextAttemptAt => dateTime().nullable()();
  TextColumn get lastError => text().nullable()();

  @override
  Set<Column> get primaryKey => {id};
}

Three details that took bug reports to learn:

idempotencyKey is the server's contract, not yours#

Every push request sends an Idempotency-Key header. The server stores it keyed to the resulting row id. If the same key arrives twice, the server returns the original response without re-executing. This is the only way to survive a phone that pushes, the response gets lost, and the phone retries.

We generate the key on the client at queue-insert time, not at push time. Generating it later means a retry might hash differently and the server treats it as a new request.

status has four states, not two#

You will be tempted to use pending and done. You need in_progress because a phone can crash mid-push, and failed because some errors are permanent (validation errors, deleted parents) and should not be retried forever. Without these two states the queue either retries permanent errors in a loop or drops valid items on a temporary one.

nextAttemptAt is set, not computed#

We learned this twice. The retry interval cannot be computed at scan time ("if attempts > 0 wait N seconds"); it has to be stored when the failure happens, with jittered backoff baked in. Otherwise every device on the same broken network retries at the same instant, every interval, until they succeed or the server falls over. We had this happen on a Tuesday morning. It is not fun.

The push loop#

The push loop runs every few seconds while the app is foregrounded:

Future<void> pushOnce() async {
  final batch = await db.queueRepo.claimBatch(
    max: 20,
    includeStatuses: const ['pending'],
    where: 'next_attempt_at IS NULL OR next_attempt_at <= ?',
  );
  if (batch.isEmpty) return;

  // Mark every claimed item in_progress in one transaction so a crash
  // here does not strand them in a half-claimed state.
  await db.queueRepo.markInProgress(batch.map((b) => b.id).toList());

  try {
    final response = await api.batchPush(batch);
    await db.queueRepo.applyPushResponse(batch, response);
  } catch (e) {
    // Network blip. Reset the batch to pending with backoff.
    await db.queueRepo.resetForRetry(batch.map((b) => b.id).toList());
  }
}

Two lines of that loop are non-obvious:

Always reset on the catch

The most common bug in homemade sync queues is forgetting to reset the batch on error. The items stay in_progress forever, the next loop skips them because they are not pending, and the user's data sits stuck. The finally-reset pattern (or a "stranded items" reaper job) catches this.

The other one is the claimBatch query: it must filter next_attempt_at inside the same transaction that flips status, otherwise two concurrent push loops can claim the same item twice.

Foreign-key resolution before the push#

Here is the scenario that breaks naive engines:

  1. The user creates a contact while offline. We assign it local_id = uuid-A.
  2. The user creates an invoice for that contact. We store contact_id = uuid-A in the invoice payload.
  3. The phone gets connectivity. We push the contact, get server_id = 4729.
  4. We push the invoice. The server says "contact 4729 does not exist." Because we sent uuid-A.

The fix is to rewrite the FK at push time, after the parent has been pushed and we know its server id. Walk the payload, find every field ending in _id, look up the local-to-server mapping for that entity, and substitute. We have a small whitelist of FK fields per entity to avoid touching anything else.

function resolvePayload(payload: any, entityType: string): any {
  const fkFields = FK_MAP[entityType] ?? [];
  const out = { ...payload };
  for (const field of fkFields) {
    const localId = out[field];
    if (typeof localId === 'string' && localId.startsWith('local-')) {
      const serverId = serverIdMap.get(localId);
      if (serverId == null) {
        throw new ParentNotSyncedError(field, localId);
      }
      out[field] = serverId;
    }
  }
  return out;
}

If the parent has not synced yet (the lookup misses), we throw and skip the row this round. The next round, the parent will have its server id, and the child can go through.

What's next#

This part of the engine is structurally simple. The next part, ordering, is where the abstractions start earning their keep: when two clients disagree about the same row, who wins, and how do you make the answer both correct and explainable to the user. That is part two.

The whole series page tracks what we have shipped and what is still being written.