Designing a Go Service Around Work That Takes Time
Some backend requests finish quickly. Others start work that continues after the original request has ended. That difference matters when designing a service. If a request starts a longer process, the caller needs to kn
Some backend requests finish quickly. Others start work that continues after the original request has ended.
That difference matters when designing a service. If a request starts a longer process, the caller needs to know whether the work finished, whether it is still running, and what happens if the connection disappears.
Separate the request from the work
An HTTP request has a context and usually a deadline. If the client disconnects or the deadline expires, the request context is cancelled.
That is useful when the work should stop with the request. It can cause problems when the work is meant to continue independently, such as generating a report or coordinating several steps.
The service needs to decide which behaviour it wants. If the task should stop when the caller goes away, pass the request context through to the work. If the task must continue, it needs a lifecycle that does not depend on the original request remaining open.
This is not just a Go detail. It is part of the service contract.
Return an operation ID
For work that continues after the request returns, one common approach is to accept the request, create an operation record and return an ID.
type StartResponse struct {
OperationID string `json:"operation_id"`
Status string `json:"status"`
}
The client can use that ID to check progress:
func (h *Handler) Start(w http.ResponseWriter, r *http.Request) {
operationID, err := h.operations.Start(r.Context(), r.Body)
if err != nil {
http.Error(w, "could not start operation", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
_ = json.NewEncoder(w).Encode(StartResponse{
OperationID: operationID,
Status: "accepted",
})
}
This snippet shows the shape of the interaction, not a complete handler. A production implementation should also validate and decode the request body, return suitable errors, and define how the operation record is created.
The response means the service accepted the work. It does not mean the work has finished. The API should make that distinction clear and provide a way to retrieve the operationβs current status.
Make repeated submissions safe
Clients retry when connections fail. If a service creates a new operation for every repeated request, one user action may start the work more than once.
An idempotency key can help identify repeated submissions. The service should associate the key with the operation it created and return that operation when it sees the same key again.
As with payment processing, the key only works if the server checks and records it. A client-generated ID is not protection on its own.
Define what happens when work fails
A long-running task can fail after the original request has returned. The service should record the outcome and expose a useful status to the client.
A simple state model might include queued, running, completed and failed. Those names are examples; what matters is that βacceptedβ and βcompletedβ are not treated as the same thing.
For a task with multiple steps, one final failed status may not be enough for troubleshooting. The service may need to record which step failed, whether earlier steps completed, and whether retrying is safe.
Retries should be deliberate. Retrying every error forever can keep a failing operation alive and consume resources. A finite retry policy and a visible failure state make it clearer when an operation needs intervention.
Observe the operation, not just the HTTP request
A successful HTTP response may mean only that the service accepted the work. It does not tell us whether the worker eventually completed it.
Monitoring should distinguish request handling from task execution. Depending on the service, it may be useful to measure how many operations are waiting, how many are running, how long they take and how many fail.
Carry the operation ID through logs and traces so engineers can follow a task from submission to completion. Avoid logging sensitive request data unnecessarily.
Keep the contract clear
An asynchronous API should explain what its response means, how the client can check the operation, and what happens if the client submits the same request again.
The design question is not simply, βCan this handler start a goroutine?β It is, βWho owns this work after the request ends, how can its outcome be observed, and how does the system recover if a step fails?β
Answering those questions early makes long-running work easier to operate and safer to retry.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.