Draftr: Cut a Slow Endpoint's p95 from ~2.3 s to ~0.24 s by Moving S3 Deletes to a Background Task
Used the telemetry FastAPI/OpenTelemetry gives almost for free, wired it into Grafana, and
spotted that one endpoint (PUT /api/drawings/{id}/commit) had a p95 around 2.3 s while
every other route was far faster. The cause was synchronous S3 DeleteObject calls on the
request path; moving them to a FastAPI BackgroundTask dropped that endpoint's p95 to
roughly 0.24 s.
- Project: draftr — private Excalidraw workspace (Next.js + FastAPI on AWS serverless)
- Date: 2026-10-04
- Role: sole developer — instrumentation, diagnosis, fix
Context
Draftr's backend is a FastAPI app on AWS Lambda (container image, behind the Lambda Web
Adapter running uvicorn), storing drawing scenes and thumbnails in S3 and metadata in
a database. Traces, metrics, and logs go over OTLP to Grafana Cloud.
/commit is the save endpoint: the client uploads the new scene (and thumbnail) to S3
with presigned URLs, then calls /commit to atomically point the drawing at the new
version. Once committed, the previous scene/thumbnail objects are no longer needed and
get deleted.
Problem
After wiring the telemetry into Grafana I looked at p95 latency per route. Everything was
comfortably fast except /commit:
| Route | p95 (around deploy time) |
|---|---|
PUT /api/drawings/{id}/commit |
~2.3 s |
GET /api/drawings/{id} |
~0.09 s |
GET /api/library |
~0.04 s |
POST /api/drawings/{id}/upload-urls |
~0.69 s |
(Pulled from Grafana Cloud Prometheus, http_server_request_duration_seconds_bucket.
Low traffic, so these are small samples — directionally right, not benchmark-grade.)
One outlier route is exactly what per-route percentiles are for: an average over all endpoints would have hidden it.
What I Did
Read the handler. After the database commit succeeded, it deleted the superseded objects from S3 inline, before returning:
# before: the client waits for S3 deletes it doesn't care about
blobs.delete(*(k for k in (old_scene, old_thumb) if k not in (body.scene_key, body.thumb_key)))
return {"version": version}
The client doesn't need the delete to finish to know the commit succeeded — the data is
already safe. So the delete is cleanup, not part of the answer. FastAPI's
BackgroundTasks runs a callable after the response is sent:
@router.put("/drawings/{id}/commit")
def commit(id: str, body: s.Commit, bg: BackgroundTasks, ...):
...
# after the response
bg.add_task(blobs.delete, *(k for k in (old_scene, old_thumb) if k not in (body.scene_key, body.thumb_key)))
return {"version": version}
Applied the same change to set_thumb, which had the identical pattern. Commit:
38704ed ("Run commit/thumb S3
deletes after the response").
Result
Same endpoint, p95 in Grafana (20-minute rate window, 2026-10-04):
- Before the change: ~2.3 s (2.28–2.35 s)
- After the change: ~0.24 s
Roughly a 10× improvement on the one slow endpoint, with a three-line diff per handler.
Takeaways
- Per-route percentiles find what averages hide. One slow route among fast ones only shows up when you break latency down by route and look at p95, not the mean.
- Ask whether the client needs the work to finish. Cleanup, notifications, and cache invalidation usually don't belong on the response path.
- Trade-off I accepted: background deletes are best-effort. On Lambda the execution environment can be frozen after the response is returned, so a delete may be delayed or skipped, leaving an orphaned object. That's acceptable here because orphans are only wasted storage (S3 versioning plus a 7-day retention rule keeps recovery possible), but it would not be acceptable for work that must happen. For that, use a queue.
- Interview version: "I added observability, found one endpoint 5-10× slower than the rest from its p95, traced it to synchronous S3 deletes on the request path, and moved them off it — p95 went from ~2.3 s to ~0.24 s."