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
  1. Home/
  2. Train/
  3. Labs/
  4. Build a queue in 60 minutes
LAB01

Build a queue in 60 minutes

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.

Start the lab↓Open the repo↗
min
60
STACK
TypeScript
LEVEL
Intermediate
/02 — SETUP

What you will build

A working sync queue in TypeScript with idempotency keys, retries, and the test harness.

Prerequisites

  1. 01TypeScript 5+ and Node 20+ installed locally.
  2. 02Comfort with async/await and a basic understanding of generics.
  3. 03You have written code that touches a network and seen it retry.
/03 — WALKTHROUGH
/04 — RESOURCES

Take it with you.

  • ⌥
    RepositoryStarter code on GitHub.
  • ↓
    Zip downloadSame code, no git required.
/USED IN

Courses that pair with this lab.

  • Sync engines, end-to-end→
/05 — NEXT
Try this next02Add audit logs to a Laravel appAnother short, free lab.→
Or go deep

Sync engines, end-to-end

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.

See the course→

The minimum viable queue#

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:

type JobStatus = 'pending' | 'in_progress' | 'done' | 'failed';

interface Job<T = unknown> {
  id: string;
  idempotencyKey: string;
  payload: T;
  status: JobStatus;
  attempts: number;
  lastError?: string;
}

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).

Step 1: the in-memory queue#

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.

class Queue<T> {
  private jobs = new Map<string, Job<T>>();

  enqueue(payload: T, idempotencyKey: string): Job<T> {
    const id = crypto.randomUUID();
    const job: Job<T> = {
      id,
      idempotencyKey,
      payload,
      status: 'pending',
      attempts: 0,
    };
    this.jobs.set(id, job);
    return job;
  }

  pending(): Job<T>[] {
    return [...this.jobs.values()].filter((j) => j.status === 'pending');
  }
}

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."

Step 2: the flush loop#

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.

type Pusher<T> = (job: Job<T>) => Promise<void>;

async flush(pusher: Pusher<T>): Promise<void> {
  for (const job of this.pending()) {
    job.status = 'in_progress';
    job.attempts += 1;
    try {
      await pusher(job);
      job.status = 'done';
    } catch (e) {
      job.status = job.attempts >= 5 ? 'failed' : 'pending';
      job.lastError = String(e);
    }
  }
}

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.

Step 3: the test harness#

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.

Step 4: the idempotency contract#

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.

Step 5: where this falls short#

This queue does the right things for small workloads. It also lies about three things you will eventually care about:

  1. Persistence. Refresh the page and the queue is gone. Real apps need a storage layer that survives reload.
  2. Concurrency. The flush loop runs jobs serially. Real apps want bounded parallelism, not infinite, not one-at-a-time.
  3. 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.

Closing the loop#

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.