Taking Website Screenshots with Django, Celery, and Playwright

Last updated November 3rd, 2026

In this tutorial, we'll build a Django app that takes website screenshots using Playwright. We'll start by capturing screenshots directly in the view and then move the work to a background task using Celery. Finally, we'll explain how to run browser automation at scale using Browser Use's browser infrastructure.

Requirements:

  • Python v3.12 or newer
  • Docker (with Compose) installed

Contents

Project Introduction

To make the tutorial easier to follow, I've prepared a dockerized Django project. The project includes three services: web for Django, celery for background tasks, and redis for the task queue and results.

Interested in building the starter project yourself? Check out the Django Celery guide.

Right now, the project lets you submit a website URL, choose between a full-page or viewport screenshot, and store the request in the database. No screenshots are taken yet.

First, clone the base branch of the django-website-screenshots repo:

$ git clone https://github.com/duplxey/django-website-screenshots.git \
    --single-branch --branch base && cd django-website-screenshots

Build and start the services:

$ docker compose up --build -d

Then apply the database migrations:

$ docker compose exec web python manage.py migrate

Open your favorite web browser and navigate to http://localhost:8000. Test whether the screenshot request saves successfully by filling out the form. After you submit, the request should appear in the table with status FAILED, because screenshot functionality isn't implemented yet.

Django Website Screenshots

Install Playwright

To take screenshots, we'll use a package called Playwright.

Playwright is a browser automation library that lets you control Chromium, Firefox, and WebKit from Python. You can use it to navigate websites, fill out forms, click buttons, take screenshots, or do automated testing.

First, add it to requirements.txt:

playwright==1.64.1

We now have to make two changes in our Dockerfile.

First, define the PLAYWRIGHT_BROWSERS_PATH environment variable, which tells Playwright where to install and find browsers:

# Dockerfile

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PLAYWRIGHT_BROWSERS_PATH=/ms-playwright

Then install Chromium and its system dependencies right after pip install:

# Dockerfile

RUN pip install --no-cache-dir -r requirements.txt
RUN python -m playwright install --with-deps chromium  # new

Rebuild the image and recreate the web and worker containers:

$ docker compose up --build -d

We're now ready to take our first screenshot.

Capture a Screenshot

To capture a screenshot using Playwright, we'll have to do the following:

  1. Start a Playwright-controlled headless Chromium instance.
  2. Configure the browser, such as setting the width and the height.
  3. Navigate to the desired page and call screenshot().
  4. Close the browser instance.

Create a new file called utils.py in the screenshots app with the following contents:

# screenshots/utils.py

from urllib.parse import urlsplit
from playwright.sync_api import sync_playwright


def take_screenshot(url, full_page=True):
    if urlsplit(url).scheme not in {"http", "https"}:
        raise ValueError("Only HTTP and HTTPS URLs are supported.")

    with sync_playwright() as playwright:
        browser = playwright.chromium.launch()
        try:
            page = browser.new_page(viewport={"width": 1280, "height": 800})
            page.set_default_timeout(30_000)
            response = page.goto(url, wait_until="load")
            if response is not None and response.status >= 400:
                raise ValueError(f"Website returned HTTP {response.status}.")
            image = page.screenshot(full_page=full_page, type="png")
        finally:
            browser.close()

    return image

The helper function returns PNG bytes for either the 1280x800 viewport or the full scrollable page. It also checks for any errors and propagates them to the caller.

Moving along, let's utilize the helper in index_view():

# screenshots/views.py

@require_http_methods(["GET", "POST"])
def index_view(request):
    form = ScreenshotForm(request.POST if request.method == "POST" else None)

    # If a form is submitted, take the screenshot, and then redirect
    if request.method == "POST" and form.is_valid():
        screenshot = form.save()
        is_full_page = screenshot.type == TYPE_FULL_PAGE
        try:
            image = take_screenshot(screenshot.url, is_full_page)
            screenshot.file.save(
                f"{uuid4()}.png", 
                ContentFile(image), 
                save=False,
            )
            screenshot.status = STATUS_SUCCESS
            screenshot.save()
        except Exception:
            Screenshot.objects.filter(pk=screenshot.pk).update(
                status=STATUS_FAILED, 
                updated_at=timezone.now(),
            )
        return redirect("index")

    # Otherwise, fetch the table data and display the template
    table = ScreenshotTable(Screenshot.objects.all())
    RequestConfig(request, paginate={"per_page": 10}).configure(table)
    return render(
        request,
        "screenshots/index.html",
        {
            "form": form,
            "table": table,
        },
    )

Don't forget about the imports at the top of the file:

# screenshots/views.py

from uuid import uuid4
from django.core.files.base import ContentFile
from django.utils import timezone
from .models import STATUS_FAILED, STATUS_SUCCESS, TYPE_FULL_PAGE
from .utils import take_screenshot

The view calls the helper, saves the PNG through the model's image field, and marks the capture as SUCCESS or FAILED. Test it by navigating to http://localhost:8000 and taking a screenshot of a random site.

Taking a screenshot inside the view blocks the worker handling the request until the capture finishes. The user has to wait for a response, and that worker can't handle another request in the meantime. In the next section, we'll move the capture to a background task.

Using Background Tasks

Create a file called tasks.py in the screenshots app with the following contents:

# screenshots/tasks.py

from uuid import uuid4

from celery import shared_task
from django.core.files.base import ContentFile
from django.utils import timezone

from .models import STATUS_FAILED, STATUS_SUCCESS, Screenshot
from .utils import take_screenshot


@shared_task
def capture_screenshot_task(screenshot_id, url, full_page=True):
    screenshot = Screenshot.objects.get(pk=screenshot_id)

    try:
        image = take_screenshot(url, full_page=full_page)
        screenshot.file.save(
            f"{uuid4()}.png", 
            ContentFile(image),
            save=False,
        )
        screenshot.status = STATUS_SUCCESS
        screenshot.save()
    except Exception:
        Screenshot.objects.filter(pk=screenshot_id).update(
            status=STATUS_FAILED, 
            updated_at=timezone.now(),
        )
        raise

    return screenshot.file.name

The file defines and registers a new Celery task using the @shared_task decorator. The task calls the same take_screenshot() helper and handles the image and status updates. We re-raise exceptions, so Celery also records failures.

Next, trigger the task from index_view():

# screenshots/views.py

logger = logging.getLogger(__name__)


@require_http_methods(["GET", "POST"])
def index_view(request):
    form = ScreenshotForm(request.POST if request.method == "POST" else None)

    if request.method == "POST" and form.is_valid():
        screenshot = form.save()
        try:
            capture_screenshot_task.delay(
                screenshot.pk,
                screenshot.url,
                full_page=screenshot.type == TYPE_FULL_PAGE,
            )
        except OperationalError:
            Screenshot.objects.filter(pk=screenshot.pk).update(
                status=STATUS_FAILED, updated_at=timezone.now(),
            )
            logger.exception("Could not queue screenshot %s", screenshot.pk)
            messages.error(
                request, "Could not start the capture. Please try again.",
            )
        return redirect("index")

    # ...

And add the imports at the top of the file:

import logging
from django.contrib import messages
from kombu.exceptions import OperationalError
from .tasks import capture_screenshot_task

Calling .delay() queues the capture through Redis, allowing Django to redirect without waiting for it. If queuing fails, we mark the screenshot as failed and show an error.

Restart the worker to load the task:

$ docker compose restart celery

Submit another URL, then refresh after a few seconds to see the result. The capture now runs in the background without blocking the Django thread.

Polling for Status

The capture now runs in the background, but we still have to refresh the page to see the result. To update the table automatically, we'll use polling. Our JavaScript code will periodically check the status of each pending screenshot and update its row when the capture finishes.

First, add a status_view() to screenshots/views.py:

# screenshots/views.py

from django.http import JsonResponse
from django.shortcuts import get_object_or_404


@require_http_methods(["GET"])
def status_view(request, pk):
    screenshot = get_object_or_404(Screenshot, pk=pk)
    return JsonResponse(
        {
            "status": screenshot.status,
            "label": screenshot.get_status_display(),
            "file_url": screenshot.file.url if screenshot.file else None,
        }
    )

The view fetches the screenshot by its primary key and returns its status, display label, and image URL as JSON. If the screenshot doesn't exist, it returns a 404 response.

Next, register the view by adding it to urls.py:

# screenshots/urls.py

path("screenshots/<int:pk>/status/", views.status_view, name="status"),

Our JavaScript code needs to know which rows are pending and where to check their status. To provide this information, add row_attrs to ScreenshotTable.Meta in screenshots/tables.py, keeping the existing options:

# screenshots/tables.py

class ScreenshotTable(tables.Table):
    # ...

    class Meta:
        # ...

        row_attrs = {
            "data-state": lambda record: record.status,
            "data-status-url": lambda record: reverse(
                "status", args=[record.pk],
            ),
        }

And add the import:

# screenshots/tables.py

from django.urls import reverse

Each table row now includes two HTML attributes: data-state for the screenshot's current status and data-status-url for its status endpoint.

Moving along, create a status.js file in screenshots/static/screenshots folder:

screenshots/
└── static/
    └── screenshots/
        └── status.js

Then put the following contents inside:

// screenshots/static/screenshots/status.js

async function updateScreenshot(row) {
    try {
        const response = await fetch(row.dataset.statusUrl, {
            cache: "no-store",
        });
        if (!response.ok) {
            throw new Error("Could not check screenshot status");
        }

        const data = await response.json();
        const badge = row.querySelector("[data-status]");
        const placeholder = row.querySelector("[data-file-placeholder]");
        row.dataset.state = data.status;
        badge.textContent = data.label;
        badge.classList.remove(
            "text-bg-warning", "text-bg-success", "text-bg-danger",
        );
        badge.classList.add({
            PE: "text-bg-warning",
            SU: "text-bg-success",
            FA: "text-bg-danger",
        }[data.status]);

        if (data.status === "SU" && data.file_url) {
            const link = row.querySelector("[data-file-link]");
            link.href = data.file_url;
            link.hidden = false;
            placeholder.hidden = true;
            return;
        }

        if (data.status === "FA") {
            placeholder.textContent = "Capture failed.";
            return;
        }

        placeholder.textContent = "Capturing...";
    } catch (error) {
        const placeholder = row.querySelector("[data-file-placeholder]");
        placeholder.textContent = "Reconnecting...";
    }

    setTimeout(() => updateScreenshot(row), 2000);
}

document.querySelectorAll('tr[data-state="PE"]').forEach((row) => {
    setTimeout(() => updateScreenshot(row), 1000);
});

The script finds all pending rows and checks their status after one second. It uses the template's existing data-* attributes to update the status badge and show the image link or a failure message.

If a capture is still pending, the script waits two seconds before checking again. Failed status requests display "Reconnecting..." and are retried as well. Polling stops once the image link is available, or the capture has failed.

Finally, load the script just before </body> in screenshots/templates/screenshots/index.html. The template already loads the static tag, so we only need to add:

<!-- screenshots/templates/screenshots/index.html -->

<!DOCTYPE html>
<html lang="en">
<head>
    <!-- ... -->
</head>
<body>
    <!-- ... -->
    <script src="{% static 'screenshots/status.js' %}" defer></script>
</body>
</html>

Test it by navigating to http://localhost:8000 and submitting another URL. The status and image link should now update without refreshing the page. To test a failed capture locally, try a broken URL.

Optional: Browser Automation at Scale

Running Chromium inside a Celery worker works well for smaller projects, but concurrent browsers can quickly exhaust the worker's memory. At higher volumes, you'll also face bot detection, CAPTCHAs, and blocked requests.

Browser Use's browser infrastructure lets you run your existing Playwright scripts against managed Chromium browsers. It includes stealth protections and residential proxies to help reduce blocking. Pricing is $0.02 per browser-hour plus $5/GB of residential proxy traffic, with no subscription.

Currently, Browser Use offers $1 in free credits for eligible signups.

Let's update our app to use it.

Create a new API key and add it to the environment variables in docker-compose.yaml:

# docker-compose.yaml

services:
  web:
    # ...
    environment:
      # ...
      - BROWSER_USE_API_KEY=<your-api-key>

  celery:
    # ...
    environment:
      # ...
      - BROWSER_USE_API_KEY=<your-api-key>

Don't forget to replace <your-api-key> with the actual key.

Then read it in settings.py:

# core/settings.py

BROWSER_USE_API_KEY = os.environ["BROWSER_USE_API_KEY"]

Next, update screenshots/utils.py to use a direct CDP connection instead of launching Chromium locally:

# screenshots/utils.py

from django.conf import settings

# ...


def take_screenshot(url, full_page=True):
    # ...

    with sync_playwright() as playwright:
        browser = playwright.chromium.connect_over_cdp(
            "wss://connect.browser-use.com"
            f"?apiKey={settings.BROWSER_USE_API_KEY}"
            "&proxyCountryCode=us"
        )
        # ...

Keep the rest of the helper unchanged, including the finally block that calls browser.close().

Recreate the containers to apply the environment changes:

$ docker compose up -d --force-recreate web celery

Voilà, the project now uses remote browser infrastructure instead of running Chromium instances in the Celery workers. Give it a try by submitting a new URL.

Conclusion

In this article, we built a Django screenshot app with Playwright, moved captures into Celery background tasks, and added automatic status updates. We also connected the same task to Browser Use to run the browser remotely.

The final source code is available on GitHub.

Nik Tomazic

Nik Tomazic

Nik is a software developer from Slovenia. He's interested in object-oriented programming and web development. He likes learning new things and accepting new challenges. When he's not coding, Nik's either swimming or watching movies.

Share this tutorial

Featured Course

Test-Driven Development with Django, Django REST Framework, and Docker

In this course, you'll learn how to set up a development environment with Docker in order to build and deploy a RESTful API powered by Python, Django, and Django REST Framework.

Featured Course

Test-Driven Development with Django, Django REST Framework, and Docker

In this course, you'll learn how to set up a development environment with Docker in order to build and deploy a RESTful API powered by Python, Django, and Django REST Framework.