Screencast

35. Schedule Delete

ecto liveview oban pubsub

Permanently delete database records after a delay — with the option to undo

Deleting a record immediately is straightforward, but it leaves no room for recovery. A common approach is to mark a record as deleted first, then remove it from the database after a short delay. This gives users a window to undo the action before it becomes permanent.

This screencast walks through building that pattern in Phoenix LiveView using Ecto, Oban, and Phoenix PubSub — from the database migration to the real-time table update when the job finally runs.

What this covers

  • Adding a deleted_at timestamp column to soft-delete records instead of removing them immediately
  • Scheduling a hard delete with Oban, with a configurable delay before permanent removal
  • Using Phoenix PubSub to broadcast deletions and update the LiveView table in real time — without a page refresh
  • Applying conditional CSS classes in the table component to visually mark deleted rows, using Tailwind's parent-targeting syntax
  • Implementing an undo function that clears deleted_at, which the Oban job checks before proceeding with deletion
  • Extending a shared table component with a row_deleted attribute without breaking other usages of the same component

A taste of what you'll build

Scheduling the hard delete through Oban after soft-deleting a record:

def soft_delete_brewery(%Brewery{} = brewery) do
changeset = Ecto.Changeset.change(brewery, %{deleted_at: DateTime.utc_now(:second)})
with {:ok, brewery} <- Repo.update(changeset),
{:ok, _job} <- schedule_hard_delete(brewery) do
{:ok, brewery}
end
end
defp schedule_hard_delete(brewery) do
%{id: brewery.id}
|> HardDeleteBreweryWorker.new(schedule_in: 60)
|> Oban.insert()
end

The Oban worker that runs the permanent delete — and handles the case where the record was already restored:

def perform(%Oban.Job{args: %{"id" => id}}) do
case Breweries.hard_delete_brewery(id) do
{:ok, _brewery} -> :ok
{:error, :not_found} -> :ok
end
end

The not_found case is intentional. If a user undoes the deletion before the job runs, deleted_at is cleared and the hard delete query finds nothing — the job completes cleanly either way.

Back to Screencasts