Skip to main content

Article

This Is How I Took a 4-Minute Django Task Down to 8 Seconds (Using Threading)

13 min read
This Is How I Took a 4-Minute Django Task Down to 8 Seconds (Using Threading)

Threading in Django: From 4 Minutes to 8 Seconds - A Real-World Implementation Guide

The Problem That Made Me Learn Threading

Picture this: You're an admin on a Django application. You click "Export Claims to Excel" expecting a quick download. Instead, you stare at a loading spinner for 4 minutes and 50 seconds. You can't click anything else. You can't navigate away. You're essentially frozen in time, questioning your life choices.

Or worse - you need to send reminder emails to 10000 users. You click "Send Emails," and now you're stuck waiting for each email to be processed, sent, confirmed, and logged before moving to the next one. Meanwhile, the CEO walks by asking why you're just sitting there "doing nothing."

Sound familiar? Yeah, I've been there. That's when I discovered threading and everything changed.

What The Heck Is Threading Anyway?

Let me break this down in the simplest way possible.

The Restaurant Analogy

Imagine a restaurant with one waiter (that's your traditional, synchronous code). This waiter takes an order from table 1, goes to the kitchen, waits for the food, brings it back, then moves to table 2. Every customer waits their turn. If someone orders a well-done steak that takes 30 minutes, everyone behind them is stuck waiting.

Now imagine the same restaurant with multiple waiters (that's threading). One waiter can take order from table 1 and immediately hand it to the kitchen, while another waiter takes order from table 2. The kitchen processes multiple orders simultaneously. Customers get served faster, and no one is sitting around doing nothing.

In Technical Terms

Threading is a way to run multiple operations concurrently (at the same time, or appearing to) within a single process. It allows your application to handle multiple tasks without waiting for each one to finish before starting the next.

In Python/Django, when you spawn a thread (a separate line of execution), the main program continues running while the thread does its work in the background. This is called asynchronous execution (not waiting for operations to complete before moving forward).

Think of it as hiring assistants. Your main program is the manager, and threads are assistants handling specific tasks independently.

Why Should You Care About Threading?

The Real Impact (In Numbers That Actually Matter)

Before threading, here's what my application looked like:

Excel Export (10,000 records):

  • Before: 4 minutes 50 seconds
  • After: 8 seconds
  • Improvement: 36x faster!

Email Delivery (10,000 users):

  • Before: Admin locked on page for ~15 minutes
  • After: Admin can continue working immediately, emails send in background
  • Improvement: Instant UI response

When Threading Becomes Your Best Friend

  1. Long-running operations - Database exports, report generation, data processing
  2. Bulk communications - Mass emails, SMS, notifications
  3. External API calls - Payment processing, third-party integrations
  4. I/O-bound tasks - File operations, network requests (I/O means Input/Output operations that involve waiting for external resources)
  5. Background processing - Cleanup tasks, scheduled jobs

Real-World Implementation #1: Non-Blocking Email Delivery

The Business Problem

I had an agreements system where admins needed to send Terms & Conditions reminders to users who hadn't signed. Sometimes this meant 1000+ emails. The old synchronous approach meant:

  • Admin clicks "Send Emails"
  • Browser freezes
  • Each email takes ~2-3 seconds (SMTP connection, send, log, verify)
  • Total wait time: 15-25 minutes
  • Admin can't do anything else

This wasn't just inefficient - it was unusable.

The Threading Solution

Here's how I solved it:

import threading
from django.db import close_old_connections

def _send_unsigned_emails_async(recipients, claim_label, acceptance_url, 
                                  template_path, email_subject, admin_email):
    """
    This function runs in a SEPARATE THREAD (background worker)
    The main request returns immediately, this keeps working
    """
    if not recipients:
        return

    try:
        # CRITICAL: Close old DB connections from the parent thread
        # Each thread needs its own clean database connection
        close_old_connections()

        total = len(recipients)
        success_count = 0
        failures = []

        # Now process emails one by one in the background
        for recipient in recipients:
            context = {
                "recipient": recipient,
                "claim_name": claim_label,
                "acceptance_url": acceptance_url,
                "render_mode": "html",
            }
            try:
                html_message = render_to_string(template_path, context)
                mail.send(
                    recipients=[recipient.email],
                    subject=email_subject,
                    message=None,
                    html_message=html_message,
                    priority='now',
                )
                success_count += 1
            except Exception as exc:
                logger.exception(f"Failed to send email to {recipient.email}")
                failures.append({
                    "email": recipient.email,
                    "error": str(exc),
                })

        # Send summary email to admin when done
        if admin_email:
            summary_subject = f"Email Campaign Complete - {claim_label}"
            summary_message = f"""
            Total recipients: {total}
            Successfully sent: {success_count}
            Failed: {len(failures)}
            """
            mail.send(
                recipients=[admin_email],
                subject=summary_subject,
                message=summary_message,
                priority='now',
            )
    finally:
        # Always clean up database connections
        close_old_connections()

Triggering the Thread (The Magic Part)

@login_required
def agreements_claims_view(request):
    if request.method == "POST":
        action = request.POST.get("action")
        
        if action == "send_unsigned_email" and request.user.is_superuser:
            # ... fetch recipients logic ...
            
            unsigned_recipients = [...]  # List of users to email
            
            if unsigned_recipients:
                # START THE BACKGROUND THREAD
                threading.Thread(
                    target=_send_unsigned_emails_async,
                    args=(
                        unsigned_recipients,
                        claim_label,
                        acceptance_url,
                        template_path,
                        email_subject,
                        admin_email,
                    ),
                    daemon=True,  # Thread dies when main program exits
                ).start()

                # IMMEDIATELY show success message and return
                messages.success(
                    request, 
                    f"Sending reminders to {len(unsigned_recipients)} users. "
                    f"You'll receive a summary at {admin_email} when finished."
                )
                
                return redirect('agreements_claims_view')

What's Happening Here?

  1. Admin clicks button → POST request hits the view
  2. View validates and prepares data → Get recipients, build email context
  3. Spawn thread with threading.Thread() → Background worker starts
  4. View returns immediately → Admin sees success message in ~200ms
  5. Thread continues working → Sends emails one by one independently
  6. Thread finishes and sends summary → Admin gets completion notification

Key Concepts Explained

daemon=True: This makes the thread a "daemon thread" (a background service thread). If your main Django process shuts down, daemon threads are automatically killed. This prevents orphaned threads running forever.

close_old_connections(): Django's ORM maintains database connections per thread. When you spawn a new thread, you MUST close the parent's connections first. Otherwise, you'll get "connection already closed" errors or database connection leaks (connections that stay open unnecessarily, eventually exhausting your database's connection limit).

target=_send_unsigned_emails_async: This is the function that will run in the background thread.

args=(...): Arguments to pass to the target function. Must be a tuple.

Real-World Implementation #2: Lightning-Fast Excel Exports

The Original Nightmare

Exporting 13,000 claim records to Excel was taking 4 minutes and 50 seconds. Users would click export and literally get coffee. Some thought it was broken and refreshed, losing all progress.

The bottleneck? Processing every record, formatting cells, styling headers, auto-adjusting columns - all while the HTTP request was still open. The browser was locked waiting for the response.

The Threading Transformation

Instead of making the user wait, I implemented a task-based system with three endpoints:

  1. Start Export - Spawn thread, return task ID immediately
  2. Check Status - Poll to see if export is ready
  3. Download File - Grab the completed file
import threading
import uuid
import tempfile
from datetime import datetime

# Global dictionary to track export tasks
EXPORT_TASKS = {}

def _export_claims_async(task_id, claim_type, claim_model):
    """
    This runs in a background thread and builds the Excel file
    No user is waiting on an HTTP connection - we're free to take our time
    """
    try:
        close_old_connections()

        # Update task status so frontend can track progress
        EXPORT_TASKS[task_id]['status'] = 'processing'

        # Get the data
        fields, headers = _get_claim_fields_and_headers(claim_type)
        queryset = claim_model.objects.select_related('car').all()
        total_records = queryset.count()

        # Create Excel workbook with openpyxl
        wb = Workbook()
        ws = wb.active
        ws.title = CLAIM_TYPE_LABELS.get(claim_type, 'Export')[:31]

        # Style the headers (bold, colored, bordered)
        header_font = Font(bold=True, color="FFFFFF", size=11)
        header_fill = PatternFill(start_color="4472C4", 
                                   end_color="4472C4", 
                                   fill_type="solid")
        
        # Write headers
        for col_idx, header in enumerate(headers, start=1):
            cell = ws.cell(row=1, column=col_idx, value=header)
            cell.font = header_font
            cell.fill = header_fill
            cell.border = border

        # Write all data rows
        queryset_values = queryset.values_list(*fields)
        for row_idx, record in enumerate(queryset_values, start=2):
            for col_idx, value in enumerate(record, start=1):
                formatted_value = _format_cell_value(value)
                cell = ws.cell(row=row_idx, column=col_idx, 
                              value=formatted_value)
                cell.border = border

        # Auto-adjust column widths for readability
        for col_idx, column_cells in enumerate(ws.columns, start=1):
            max_length = max(len(str(cell.value)) for cell in column_cells)
            adjusted_width = min(max(max_length + 2, 12), 50)
            ws.column_dimensions[get_column_letter(col_idx)].width = adjusted_width

        # Save to temporary file
        temp_file = tempfile.NamedTemporaryFile(
            mode='wb',
            suffix='.xlsx',
            delete=False,
            dir=tempfile.gettempdir()
        )
        wb.save(temp_file.name)
        temp_file.close()

        # Update task with success
        timestamp = timezone.now().strftime("%Y%m%d_%H%M%S")
        filename = f"{claim_type}_export_{timestamp}.xlsx"
        
        EXPORT_TASKS[task_id]['status'] = 'completed'
        EXPORT_TASKS[task_id]['filename'] = filename
        EXPORT_TASKS[task_id]['filepath'] = temp_file.name
        EXPORT_TASKS[task_id]['record_count'] = total_records
        EXPORT_TASKS[task_id]['completed_at'] = timezone.now()

        logger.info(f"Export completed: {filename} ({total_records} records)")

    except Exception as e:
        logger.exception(f"Export failed for task {task_id}: {str(e)}")
        EXPORT_TASKS[task_id]['status'] = 'failed'
        EXPORT_TASKS[task_id]['error'] = str(e)

    finally:
        close_old_connections()

The Three Endpoints

1. Start Export (Returns Immediately)

@login_required
def export_claims_start(request):
    """User clicks 'Export' button - this endpoint responds in milliseconds"""
    claim_type = request.GET.get('claim_type')
    
    # Generate unique task ID
    task_id = str(uuid.uuid4())  # e.g., "a3b2c1d4-..."
    
    # Initialize task tracking
    EXPORT_TASKS[task_id] = {
        'status': 'started',
        'claim_type': claim_type,
        'started_at': timezone.now(),
        'filename': None,
        'filepath': None,
    }
    
    # Start background thread
    claim_model = claim_models[claim_type]
    threading.Thread(
        target=_export_claims_async,
        args=(task_id, claim_type, claim_model),
        daemon=True
    ).start()
    
    # Return immediately with task ID
    return JsonResponse({
        'task_id': task_id,
        'status': 'started',
        'message': 'Export started in background'
    })

2. Check Status (Polling)

@login_required
def export_claims_status(request):
    """Frontend polls this every 2 seconds to check progress"""
    task_id = request.GET.get('task_id')
    
    if task_id not in EXPORT_TASKS:
        return JsonResponse({'error': 'Invalid task'}, status=400)
    
    task = EXPORT_TASKS[task_id]
    
    response_data = {'status': task['status']}
    
    if task['status'] == 'completed':
        response_data['filename'] = task['filename']
        response_data['record_count'] = task['record_count']
        response_data['download_url'] = f"/download?task_id={task_id}"
    elif task['status'] == 'failed':
        response_data['error'] = task['error']
    
    return JsonResponse(response_data)

3. Download File

@login_required
def export_claims_download(request):
    """User downloads the completed file"""
    task_id = request.GET.get('task_id')
    task = EXPORT_TASKS[task_id]
    
    filepath = task['filepath']
    filename = task['filename']
    
    response = FileResponse(
        open(filepath, 'rb'),
        content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
    )
    response['Content-Disposition'] = f'attachment; filename="{filename}"'
    
    # Cleanup after 60 seconds
    def cleanup_file():
        os.remove(filepath)
        del EXPORT_TASKS[task_id]
    
    threading.Timer(60.0, cleanup_file).start()
    
    return response

The Results

Before Threading:

  • Click export → 4 min 50 sec loading spinner → Download
  • User can't do anything else
  • Looks broken
  • Browser timeout risk

After Threading:

  • Click export → Instant "Processing..." message → Continue working
  • After 8 seconds → Automatic download prompt
  • Professional experience
  • Can start multiple exports if needed

The 8-second optimization came from:

  • Using values_list() instead of full model instances (reduces memory)
  • select_related() to avoid N+1 queries (fetches related data in one query)
  • Direct cell writing without intermediate objects
  • Threading allows Python to work at full speed without HTTP overhead

How Threading Improves System Design

Scalability Benefits

1. Connection Pool Management Without threading, every operation ties up a database connection for its entire duration. With threading:

  • Short-lived connections for quick operations
  • Main thread releases connection immediately
  • Background threads get their own connections
  • Overall: Better connection pool utilization

2. Resource Optimization Instead of blocking precious web server workers (Gunicorn/uWSGI processes), threading offloads work:

  • Web workers handle HTTP requests only
  • Background threads handle processing
  • Result: More concurrent users supported

3. Fault Isolation If an email fails, the thread handles it gracefully without crashing the main request. The user never sees the error - they just get a summary later.

The Threading Mental Model

Think of your Django application as a restaurant kitchen:

  • Main Process = Head Chef (coordinates everything)
  • Web Workers = Line Cooks (handle incoming orders/requests)
  • Threads = Prep Cooks (do background work)

When an order comes in:

  • Line cook receives it (HTTP request)
  • Line cook hands prep work to a prep cook (spawn thread)
  • Line cook immediately returns to taking new orders (return response)
  • Prep cook finishes in background (thread completes)

This keeps your line cooks (web workers) free to handle more orders (HTTP requests) instead of being stuck doing prep work (long operations).

Real-World System Architecture

Here's how the complete system flows:

┌─────────────────────────────────────────────────────┐
│  FRONTEND (React/HTML)                              │
│  - User clicks "Send Emails" or "Export"           │
│  - Displays instant feedback                        │
│  - Polls for status updates                         │
└────────────────┬────────────────────────────────────┘
                 │
                 ▼
┌─────────────────────────────────────────────────────┐
│  DJANGO VIEW (Main Thread)                          │
│  - Validates request                                │
│  - Spawns background thread                         │
│  - Returns immediately with task ID                 │
│  - Time: ~200ms                                     │
└────────────────┬────────────────────────────────────┘
                 │
                 ├──────────────┐
                 │              │
                 ▼              ▼
┌─────────────────────┐  ┌──────────────────────┐
│  BACKGROUND THREAD  │  │  STATUS ENDPOINT     │
│  - Process data     │  │  - Returns progress  │
│  - Send emails      │  │  - Polled every 2s   │
│  - Build Excel      │  │                      │
│  - Update status    │  └──────────────────────┘
│  - Time: Minutes    │
└─────────────────────┘
         │
         ▼
┌─────────────────────────────────────────────────────┐
│  COMPLETION                                         │
│  - Send summary email (for emails)                  │
│  - Store file path (for exports)                    │
│  - Update task status to 'completed'                │
│  - Frontend auto-downloads or shows notification    │
└─────────────────────────────────────────────────────┘

Production Considerations

For Small to Medium Apps (< 10k users)

Threading with in-memory task tracking (like my EXPORT_TASKS dict) works great:

  • Simple to implement
  • No additional infrastructure
  • Perfect for admin actions
  • Low latency

For Large Scale Apps (> 10k users)

Move to a proper task queue:

Celery + Redis:

from celery import shared_task

@shared_task
def send_emails_async(recipient_ids):
    recipients = User.objects.filter(id__in=recipient_ids)
    for recipient in recipients:
        send_email(recipient)

Benefits:

  • Distributed across multiple workers
  • Automatic retry on failure
  • Scheduled tasks (cron-like)
  • Task result storage
  • Monitoring with Flower

AWS Lambda / Serverless: For sporadic heavy tasks, trigger Lambda:

  • No server management
  • Pay per execution
  • Infinite scale
  • Great for exports, reports

The Bottom Line

Threading transformed my Django application from feeling sluggish and unprofessional to feeling snappy and modern. Users are happier. Admins are more productive. The system scales better.

But remember: Threading is a tool, not a solution. Use it when:

  • Operations take > 3 seconds
  • User doesn't need immediate results
  • Task is I/O-bound (network, database, file operations)
  • You want to improve perceived performance

Don't use it when:

  • Operation is fast (< 1 second)
  • Task is CPU-intensive (use multiprocessing)
  • You need guaranteed execution (use Celery)
  • Results must be immediately returned

Final Thoughts

Threading isn't sexy. It's not the latest framework or the hottest new technology. But it's a fundamental tool that can transform user experience with minimal code changes.

The difference between a 5-minute loading screen and an instant response isn't just technical—it's the difference between users thinking your application is broken and thinking it's professional-grade.

Start small. Pick one slow operation in your application. Add threading. Measure the impact. I guarantee you'll be impressed.

And remember: the best code is code that ships and delights users. Threading helps you do both.

Now go make your application faster!


Resources and Further Reading


Found this helpful? Share it with your team. Threading knowledge is the gift that keeps on giving.

More technical deep-dives? Check out my other posts on my blog where I turn complicated technical problems into stories that actually make sense.


Written by a developer who got tired of staring at loading spinners and decided to do something about it.

Share:

Continue Reading

Explore more articles on similar topics