24 March 2025

Handling Long-Running Processes Asynchronously

  • #asynchronous-programming
  • #golang
  • #scalability

Last year (2024), I had the chance to work on a payment system. While working on it, I noticed that synchronous approaches are impractical for long-running processes—common in production-scale applications.

The Problem with Synchronous Processing

What's a Synchronous Approach?

A synchronous process executes from start to finish in a single blocking call. The client waits and receives either a success or failure response immediately.

func CreateTransfer(ctx context.Context, request CreateTransferRequest) (CreateTransferResponse, error) {
  // 1. Validate user permissions
  // 2. Verify destination account validity
  // 3. Check sender's balance
  // 4. Authorize (hold) funds
  // 5. Call payment gateway (slow!)
  // 6. Update balances based on gateway response
  // 7. Record transaction in DB
  // 8. Return final result
}

Why Synchronous Approach Fail?

  • Resource Bottlenecks: each call holds a connection thread (means more costly)
  • Timeout Risks: network instability may interrupt long-running calls
  • Poor Scalability: Handling 100 RPS for a long-running process can be very expensive

The Asynchronous Solution

instead of processing it in a single-go, lets split the process...

Step 1: Immediate Acceptance

func CreateTransfer(ctx context.Context, request CreateTransferRequest) (CreateTransferResponse, error) {
  // 1. Persist request to DB (status=PENDING)
  // 2. Publish to message queue (e.g., Kafka)
  // 3. Return 202 Accepted with status=PROCESSING
  return CreateTransferResponse{
    Status:    "PROCESSING",
    Message:   "Transaction queued",
    RequestID: requestID,
  }, nil
}

Step 2: Background Processing

func ProcessTransferWorker(ctx context.Context) {
  for {
    msg := queue.Consume()
    
    // 1. Validate all business rules
    // 2. Execute payment gateway call
    // 3. Update DB status (SUCCESS/FAILED)
    
    if err := process(msg); err != nil {
      metrics.RecordFailure()
      continue
    }
  }
}

ok, thats simple right, but there is a question how the client know when the transaction is either completed or failed ?

Step 3: Retrieve transaction status

// GET /transactions/{id}
func GetStatus(ctx context.Context, id string) (StatusResponse, error) {
  tx, err := db.GetTransaction(id)
  if err != nil {
    return nil, ErrNotFound
  }
  
  return StatusResponse{
    ID:      tx.ID,
    Status:  tx.Status,
    Updated: tx.UpdatedAt,
  }, nil
}

Sounds simple enough, right? But there's another approach we can take—without creating an extra endpoint (and you know, more endpoints mean more things to build, handle, and maintain 😅). Let’s keep that for another post.