A real-world sync queue is not a job library you install. It is a data structure you own, with idempotency, retries, and back-pressure decisions you can defend in a code review. In an hour you will write one in TypeScript, drive it with a small test harness, and have a foundation you can drop into any client.
If you liked owning the queue for an hour, the full course owns the entire sync engine: server, client, conflict resolution, and the cohort office hours where we stress-test it.
A sync queue, at its core, is a small state machine over a list of jobs. Every job moves through pending → in_progress → done or failed, and the queue's job is to make sure that motion is safe under retry: if the network drops mid-flush, you must not lose work, and you must not double-write.
We will start with the smallest type that captures this:
Two fields earn their place here: idempotencyKey and attempts. The first is the contract with the server (the same key on retry must not produce a second write). The second is the contract with yourself (you need a stop condition).
The first version stores jobs in a Map<string, Job>. This is enough to test the state machine and the retry loop without bringing storage into the picture. Real adoption swaps this for SQLite, IndexedDB, or whatever the platform offers, but the queue interface should not care.
Two things to notice. First, the queue itself does no I/O. The flush loop is a separate concern. Second, enqueue is synchronous. Asynchronous enqueue is a footgun: it means the caller cannot atomically reason about "did this go in or not."
The flush loop walks pending jobs, hands each to a pusher function that returns a promise, and updates state from the result. The contract with the pusher is small: it either resolves, or it rejects with an error.
The branch in the catch block is the only place the back-off policy lives. Five attempts and we mark the job failed and walk away. A real implementation will also want exponential delay between attempts; the simplest version is setTimeout(flush, 2 ** attempts * 1000) from the caller, not from inside the queue.
Without a test, you cannot prove this is correct under retry. The test harness is a fake pusher that simulates network failures by index. Drop the file at src/queue.test.ts:
import { describe, it, expect, vi } from 'vitest';import { Queue } from './queue';describe('Queue', () => { it('retries failed jobs until they succeed', async () => { const q = new Queue<string>(); q.enqueue('A', 'key-A'); let failures = 2; const pusher = vi.fn(async () => { if (failures-- > 0) throw new Error('network'); }); await q.flush(pusher); await q.flush(pusher); await q.flush(pusher); expect(pusher).toHaveBeenCalledTimes(3); expect(q.pending()).toHaveLength(0); }); it('marks the job failed after 5 attempts', async () => { const q = new Queue<string>(); q.enqueue('B', 'key-B'); const pusher = vi.fn(async () => { throw new Error('always'); }); for (let i = 0; i < 5; i++) await q.flush(pusher); expect(pusher).toHaveBeenCalledTimes(5); expect(q.pending()).toHaveLength(0); });});
Run it with pnpm vitest run. Both tests should pass.
Right now, the idempotencyKey is stored but unused. The next change makes it earn its place: the pusher uses the key as an HTTP header, and the server uses it to deduplicate writes. Below is the production version of pusher.
async function pushToServer(job: Job<{ url: string; body: unknown }>) { const res = await fetch(job.payload.url, { method: 'POST', headers: { 'content-type': 'application/json', 'idempotency-key': job.idempotencyKey, }, body: JSON.stringify(job.payload.body), }); if (!res.ok) throw new Error(`HTTP ${res.status}`);}
The server contract is: same key, same payload, second time → same response, no second write. This is what makes "retry until it works" safe. Without it, every retry is rolling the dice on a duplicate.
This queue does the right things for small workloads. It also lies about three things you will eventually care about:
Persistence. Refresh the page and the queue is gone. Real apps need a storage layer that survives reload.
Concurrency. The flush loop runs jobs serially. Real apps want bounded parallelism, not infinite, not one-at-a-time.
Conflict resolution. When two clients enqueue contradictory writes, the server has to choose. The queue cannot.
All three are exactly what the full Sync engines, end-to-end course covers, with the same code style and the same shape of decision-making. This lab is the first chapter; the course is the rest.
You should now have a queue file, a test file, and an idempotency-aware pusher. Run the tests one more time, then point the queue at a real endpoint of your choice. The hour is up. The queue is yours.