Article
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
- Long-running operations - Database exports, report generation, data processing
- Bulk communications - Mass emails, SMS, notifications
- External API calls - Payment processing, third-party integrations
- I/O-bound tasks - File operations, network requests (I/O means Input/Output operations that involve waiting for external resources)
- 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?
- Admin clicks button → POST request hits the view
- View validates and prepares data → Get recipients, build email context
- Spawn thread with
threading.Thread()→ Background worker starts - View returns immediately → Admin sees success message in ~200ms
- Thread continues working → Sends emails one by one independently
- 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:
- Start Export - Spawn thread, return task ID immediately
- Check Status - Poll to see if export is ready
- 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
- Python Threading Documentation - Official Python docs
- Django Database Connection Management - Understanding connection lifecycle
- concurrent.futures Module - ThreadPoolExecutor patterns
- Celery Documentation - When you outgrow threading
- Django Async Views - Modern async/await in Django
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.


