Blog 3 min read

Website Screenshots in Django: A View, a Celery Task, and Caching

Add screenshot capture to a Django project without installing a browser: a view that proxies a screenshot API, a Celery task for batches, storage with the default file backend, and caching so repeats are free.

Most Django projects that need screenshots start with Selenium, spend a week on ChromeDriver versions and headless flags in Docker, and end up with a worker that leaks memory. This guide skips that stage. The browser lives behind an API; Django does what it is good at: views, tasks, storage, caching.

Settings

# settings.py
import os

SNAPRENDER_API_KEY = os.environ["SNAPRENDER_API_KEY"]
SNAPRENDER_ENDPOINT = "https://app.snap-render.com/v1/screenshot"

The requests library is enough:

pip install requests

Screenshots as an API call

Send a GET request, get a PNG back. Ads and cookie banners blocked by default.

200 free renders a month. Paid plans start at $9.

A view that returns the image

# views.py
import requests
from django.http import HttpResponse, JsonResponse
from django.conf import settings
from django.views.decorators.http import require_GET


@require_GET
def screenshot(request):
    url = request.GET.get("url", "")
    if not url.startswith(("http://", "https://")):
        return JsonResponse({"error": "url parameter required"}, status=400)

    r = requests.get(
        settings.SNAPRENDER_ENDPOINT,
        params={
            "url": url,
            "format": "png",
            "full_page": "true",
            "block_cookie_banners": "true",
            "cache_ttl": 86400,
        },
        headers={"X-API-Key": settings.SNAPRENDER_API_KEY},
        timeout=60,
    )
    if r.status_code != 200:
        return JsonResponse({"error": "capture failed", "detail": r.text}, status=502)

    response = HttpResponse(r.content, content_type="image/png")
    response["Cache-Control"] = "public, max-age=86400"
    return response

Wire it up in urls.py and /screenshot/?url=https://example.com returns a full-page PNG. The timeout=60 matters: a page that never settles should fail your request, not hold a worker forever.

Saving with the storage backend

For a model that stores a capture (a bookmark, a monitored page), write the bytes through default_storage so local disk, S3 and every other backend behave the same:

from django.core.files.base import ContentFile
from django.core.files.storage import default_storage


def save_capture(url: str, name: str) -> str:
    r = requests.get(
        settings.SNAPRENDER_ENDPOINT,
        params={"url": url, "format": "jpeg", "full_page": "true"},
        headers={"X-API-Key": settings.SNAPRENDER_API_KEY},
        timeout=60,
    )
    r.raise_for_status()
    return default_storage.save(f"captures/{name}.jpeg", ContentFile(r.content))

Batches with Celery

Capturing 200 pages inside a request is a bad idea anywhere. A Celery task keeps the web workers free and gives you retries:

# tasks.py
from celery import shared_task
import requests
from django.conf import settings


@shared_task(bind=True, max_retries=3, default_retry_delay=30)
def capture_page(self, url: str, name: str):
    try:
        r = requests.get(
            settings.SNAPRENDER_ENDPOINT,
            params={"url": url, "format": "jpeg", "cache_ttl": 86400},
            headers={"X-API-Key": settings.SNAPRENDER_API_KEY},
            timeout=60,
        )
        if r.status_code == 429:
            raise self.retry(countdown=60)
        r.raise_for_status()
    except requests.RequestException as exc:
        raise self.retry(exc=exc)
    return default_storage.save(f"captures/{name}.jpeg", ContentFile(r.content))

A 429 means the per-minute burst or the monthly quota is exhausted; retrying after a minute handles bursts, and the monthly case is visible in the dashboard. For lists above a few hundred URLs, the API's batch endpoint accepts up to 50 URLs per call and posts a webhook when they finish, which replaces the task loop entirely.

Caching twice

  • cache_ttl on the API request: repeat captures of the same URL and options are served from the API's cache and do not count against your quota.
  • Django's cache for your own view: store the storage path or the bytes keyed by URL so your server does not repeat the HTTP round trip either.
from django.core.cache import cache

path = cache.get(f"shot:{url}")
if not path:
    path = save_capture(url, slugify(url))
    cache.set(f"shot:{url}", path, 86400)

PDF output

Set format to pdf for reports and archives. The result is a page image inside a PDF, not selectable text; for text PDFs generated from your own templates, send HTML to the API instead (see the HTML to PDF API page).

Related: Python quickstart, screenshot website with Python, batch screenshots.

Frequently asked questions

Do I need Selenium or Playwright installed on the Django server?

Not with this approach. The browser runs behind the screenshot API; Django only makes an HTTP request with the requests library and stores or returns the bytes.

Where should the API key live?

In an environment variable read in settings.py (SNAPRENDER_API_KEY), the same way you handle a database password. Never in a template or a JavaScript file.

How do I avoid capturing the same page over and over?

Pass cache_ttl to the API so repeat captures are served from its cache without counting against your quota, and cache the resulting file path in Django's cache framework for your own views.

Can this run in a background job?

Yes. The Celery example below captures a list of URLs one at a time and saves each file with Django's storage backend, so it works with local disk, S3 or anything else you have configured.

Screenshots as an API call

200 free renders a month. Paid plans start at $9.

Sign up free