DEV Community

delay.run
delay.run

Posted on

How to build "Undo send" with a delayed job (and why it's harder than a timer)

Gmail popularised it: you hit Send, and for a few seconds there's an "Undo" button. Click it and the email never leaves.

There's no magic. The email isn't sent when you click Send. A job is scheduled to send it a few seconds later, and Undo cancels that job. The same pattern works for deleting records, posting comments, publishing changes, sending invoices: anything where "oops" is common and expensive.

It sounds like a five-minute feature. Then you hit the edge cases. Let's build it properly.

Why not just use a timer?

The obvious version is an in-memory timer in your web process: wait five seconds, then send, unless cancelled. It works in a demo and fails in production:

  • Deploys and restarts kill the process, and pending sends vanish silently.
  • Multiple instances mean the Undo request may hit a different server from the one holding the timer.
  • No record exists of what was scheduled, so nobody can debug "my email never sent".

The fix is to store the pending action somewhere durable and let a worker execute it.

The data model

A single table is enough to start:

import sqlite3, time, json

db = sqlite3.connect("jobs.db", isolation_level=None)
db.execute("""CREATE TABLE IF NOT EXISTS jobs (
  id INTEGER PRIMARY KEY,
  kind TEXT NOT NULL,
  payload TEXT NOT NULL,
  run_at REAL NOT NULL,
  status TEXT NOT NULL DEFAULT 'pending'  -- pending | running | cancelled | done
)""")

UNDO_WINDOW = 5  # seconds
Enter fullscreen mode Exit fullscreen mode

Every pending action is a row with a run_at time. The status column is the whole state machine: pending, then either cancelled (user clicked Undo) or running and done (worker picked it up).

Scheduling and undoing

def send_later(to, body):
    cur = db.execute(
        "INSERT INTO jobs (kind, payload, run_at) VALUES (?, ?, ?)",
        ("send_email", json.dumps({"to": to, "body": body}), time.time() + UNDO_WINDOW),
    )
    return cur.lastrowid

def undo(job_id):
    cur = db.execute(
        "UPDATE jobs SET status='cancelled' WHERE id=? AND status='pending'",
        (job_id,),
    )
    return cur.rowcount == 1  # False means it already ran (or was already undone)
Enter fullscreen mode Exit fullscreen mode

The important detail is in undo(): the UPDATE only matches rows that are still pending. If the worker already claimed the job, zero rows change and undo() returns False, so the UI can say "Too late, it's already sent" instead of lying.

The worker

def claim_due():
    """Atomically move one due job from pending to running."""
    row = db.execute(
        "UPDATE jobs SET status='running' WHERE id = ("
        "  SELECT id FROM jobs WHERE status='pending' AND run_at <= ?"
        "  ORDER BY run_at LIMIT 1"
        ") RETURNING id, kind, payload",
        (time.time(),),
    ).fetchone()
    return row

def work_once():
    while (job := claim_due()):
        job_id, kind, payload = job
        data = json.loads(payload)
        print(f"[worker] sending job {job_id} to {data['to']}")
        db.execute("UPDATE jobs SET status='done' WHERE id=?", (job_id,))
Enter fullscreen mode Exit fullscreen mode

The worker claims a job by flipping it from pending to running in a single statement. This is what makes Undo safe: either the user's UPDATE ... WHERE status='pending' wins, or the worker's does. They can't both succeed. (UPDATE ... RETURNING needs SQLite 3.35+; in Postgres you'd typically use SELECT ... FOR UPDATE SKIP LOCKED inside a transaction.)

In a real app, work_once() runs in a loop every second or so in a separate process.

Trying it out

import time
from undo import send_later, undo, work_once, db

a = send_later("ana@example.com", "See you at 3")
b = send_later("bo@example.com", "Oops, wrong person")
print("queued", a, b)

time.sleep(1)
print("undo b:", undo(b))

time.sleep(5)          # undo window passes
work_once()            # worker picks up whatever is still due

print("undo a (too late):", undo(a))
for row in db.execute("SELECT id, status FROM jobs"):
    print(row)
Enter fullscreen mode Exit fullscreen mode

Output from our run:

queued 1 2
undo b: True
[worker] sending job 1 to ana@example.com
undo a (too late): False
(1, 'done')
(2, 'cancelled')
Enter fullscreen mode Exit fullscreen mode

The cancelled email never reaches the worker, and a late Undo is correctly refused.

Edge cases worth handling

The worker's clock is the source of truth. If the browser shows a five-second countdown, the server should give a little extra margin: stop showing the button at 5 seconds, but schedule run_at at 6. That way a click in the final moment still lands before the worker.

Worker polling adds delay. If the worker polls every 2 seconds, a 5-second undo window means the email goes out somewhere between 5 and 7 seconds. That's usually fine. If it's not, poll more often, or use a scheduler that triggers at the exact time.

Crashes while running. If the worker crashes after claiming a job, it stays running forever. Add a claimed_at column and have a sweeper reset jobs that have been running for too long, and make the send itself idempotent so a retry doesn't double-send.

What the user sees meanwhile. During the undo window, the item should appear in a "sending" state in the UI. If the user refreshes the page, load pending jobs from the table so the Undo button survives the reload.

Undo is not the same as delete. Keep cancelled rows (at least for a while). When someone asks "why didn't my email send?", a row with status='cancelled' and a timestamp answers it.

Beyond email

Once you have this table and worker, you'll find more uses for it:

  • Soft-delete with a grace period: "Project deleted. Undo?" then hard-delete 30 seconds later.
  • Batching notifications: delay a "new comment" notification by a minute so three comments become one message.
  • Scheduled publishing: the same run_at column with a longer delay.

The core never changes: store the intent, delay the execution, allow a cancel, and make the worker's claim atomic.

When to stop building it yourself

The table-and-worker approach is great for a single app. It gets heavier as you add retries, exact-time triggers, visibility into what's pending, and webhooks to other services. That's the territory a hosted scheduler like delay.run covers: you schedule an HTTP call for later, cancel it if the user changes their mind, and see what ran and when. Whichever route you take, the design principles above apply.

Related reading

Where else have you used an undo window or grace period in your product? And how long is your window: 5 seconds, 30, longer? Let me know in the comments.

Top comments (0)