Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Pass handleSIGINT=False, handleSIGTERM=False, and handleSIGHUP=False to Pyppeteer’s launch() call. Flask can run request code in a worker thread, but Python allows signal handlers to be registered only by the main thread. Pyppeteer’s default launch attempts to register those handlers, which triggers the exception. Disabling the three handlers fixes that specific failure; it does not remove the need to await and close the browser correctly.

The direct fix

Update the Pyppeteer launch call used by the Flask request. The options are case-sensitive and use Pyppeteer’s camel-case spelling:

from pyppeteer import launch

async def capture(url, output_path):
    browser = await launch(
        handleSIGINT=False,
        handleSIGTERM=False,
        handleSIGHUP=False,
    )
    try:
        page = await browser.newPage()
        await page.goto(url)
        await page.screenshot({"path": output_path})
    finally:
        await browser.close()

All three launch options default to True. With those defaults, Pyppeteer tries to register handlers for SIGINT, SIGTERM, and SIGHUP. Python rejects that operation outside the main thread, and a Flask request may be running in a worker thread. Setting each option to False skips those registrations.

Keep browser cleanup in a finally block. The browser must be closed if navigation or screenshot capture raises an exception as well as when the capture succeeds.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Run the coroutine from a Flask route

Choose the route shape that matches your application. In either case, the capture coroutine must run on an event loop, and the browser should be opened and closed within the same capture operation.

For a synchronous Flask route

A synchronous view can run the coroutine with asyncio.run(). This example returns the PNG bytes directly rather than writing every request to the same output filename:

import asyncio

from flask import Flask, Response, request
from pyppeteer import launch

app = Flask(__name__)

async def capture_png(url):
    browser = await launch(
        handleSIGINT=False,
        handleSIGTERM=False,
        handleSIGHUP=False,
    )
    try:
        page = await browser.newPage()
        await page.goto(url)
        return await page.screenshot({"type": "png"})
    finally:
        await browser.close()

@app.get("/screenshot")
def screenshot():
    url = request.args.get("url")
    if not url:
        return {"error": "Provide a url query parameter."}, 400

    image = asyncio.run(capture_png(url))
    return Response(image, mimetype="image/png")

Call the route with a URL query parameter, for example /screenshot?url=https%3A%2F%2Fexample.com. The example validates that a value was supplied, but it is not a complete URL security policy. If callers can choose the destination, validate permitted schemes and hosts before navigating; an unrestricted screenshot endpoint can be abused to make your server request destinations that should not be reachable from it.

asyncio.run() is appropriate here when the synchronous view does not already run inside an event loop. Do not call it from a thread that already has a running loop; use an async view in that execution model instead of trying to nest event loops.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

For an async Flask view

If the route is already asynchronous, await the capture coroutine directly. Flask documents that its WSGI async support starts an event loop in a thread for an async request; the event loop itself is not the signal error. Pyppeteer’s signal registration is.

from flask import Flask, Response, request
from pyppeteer import launch

app = Flask(__name__)

async def capture_png(url):
    browser = await launch(
        handleSIGINT=False,
        handleSIGTERM=False,
        handleSIGHUP=False,
    )
    try:
        page = await browser.newPage()
        await page.goto(url)
        return await page.screenshot({"type": "png"})
    finally:
        await browser.close()

@app.get("/screenshot")
async def screenshot():
    url = request.args.get("url")
    if not url:
        return {"error": "Provide a url query parameter."}, 400

    image = await capture_png(url)
    return Response(image, mimetype="image/png")

Use one route version, not both with the same URL rule. If you need a file rather than response bytes, pass a unique per-request path to page.screenshot({"path": output_path}) and arrange to remove that file after Flask has finished sending it. Avoid a shared fixed filename: concurrent requests could overwrite one another.

What the traceback means—and what it does not

The characteristic message, ValueError: signal only works in main thread, points to process signal registration. In the matching Pyppeteer error case, the traceback enters Python’s signal.signal from pyppeteer.launch; disabling the three launch handlers is the accepted fix.

This is not, by itself, evidence of a bad selector, a failed page load, or a problem with the page’s JavaScript. Those can be separate problems after launch. First fix the signal registration failure; then inspect any new exception at its own failing call.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Choose request-bound capture or durable background work

A capture that finishes quickly can run as part of a request: await one capture, close its browser, and return its result. The client remains waiting for the operation, so this pattern is best when the expected capture duration fits your application’s request and worker limits.

Do not use an unfinished Flask task as a job queue

Flask’s async-view support does not make an unfinished task durable. Flask documents that tasks still running when the view’s event loop stops are cancelled. Starting work with asyncio.create_task() and immediately returning a response therefore does not guarantee that the capture will finish.

Use a task queue for work that must outlive the request

If the capture needs retries, must continue after the client disconnects, or may take longer than a request should remain open, submit a job to a task queue and return a job identifier. The worker should own its browser lifecycle: launch with the signal options disabled if it runs outside the main thread, close the browser in finally, and report success or failure through the job result. Flask’s documentation points to a task queue for background work.

Consider an ASGI execution model when the app needs a persistent async loop

Flask documents serving a Flask application through an ASGI adapter when a continuously running async loop is needed. If the application is primarily asynchronous, Flask also points to Quart, an ASGI-based reimplementation. These are execution-model choices, not necessary changes just to fix this particular Pyppeteer exception.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Pyppeteer maintenance and browser setup

The Pyppeteer repository describes the project as unmaintained and recommends Playwright Python. For an existing application, the three disabled signal handlers can address this thread-related failure without requiring an immediate migration. For a new project, weigh that quick fix against adopting the maintained alternative recommended by Pyppeteer’s own repository.

Pyppeteer’s README says first use may download approximately 150 MB of Chromium. Account for that download and the browser’s runtime needs in deployment planning, especially where workers have limited disk space, memory, or outbound access. The Pyppeteer launch reference describes the loop option as experimental; do not add it as a supposed fix for the signal-registration error.

Troubleshooting common follow-up failures

The same signal error still appears

  • Check that the failing launch() call—not just another launch elsewhere in the application—has all three options set to False.
  • Check the exact option capitalization: handleSIGINT, handleSIGTERM, and handleSIGHUP.
  • Read the traceback from the first failing frame. If it still reaches signal registration inside pyppeteer.launch, the call raising the exception has not received the intended options.

The coroutine is never awaited or the route returns before capture

In an async view, use await capture_png(url). In a synchronous view, run the coroutine with asyncio.run(capture_png(url)) when no event loop is already running in that thread. Merely calling an async def function creates a coroutine; it does not perform the capture.

Browser processes or resources remain after an error

Ensure that both successful and failing paths pass through finally: await browser.close(). Put page navigation and screenshot work inside the try after the browser launches, rather than closing only after the screenshot line.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

The first capture is slow or browser startup fails in deployment

Allow for Pyppeteer’s first-use Chromium download, documented by the project README as approximately 150 MB. A deployment that cannot download it or does not have enough available storage may fail before navigation begins. Check the deployment environment and browser setup separately from the signal flags; those flags do not install Chromium or repair a failed download.

The page navigation fails after launch

Once the signal exception is gone, a failure from page.goto() is a different problem. Check that the requested page is reachable from the server and that the supplied URL is valid. Do not treat disabling signal handlers as a remedy for navigation, network, or page-content failures.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Or skip the browser setup

If your application only needs a screenshot result, ScreenshotNeo offers a website screenshot API: your Flask code makes an HTTP request rather than launching local Pyppeteer Chromium. This does not turn a long capture into durable background work; use a task queue if your request should return before processing finishes.

Python one-call example, using the API base URL and documented request pattern:

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
    timeout=90,
)
open("shot.webp", "wb").write(r.content)

See the ScreenshotNeo documentation for request options. Cookie banners, popups, and chat widgets are removed before the shot; bot checks, blank pages, and failed loads are never billed. Its MCP server lets AI agents use screenshot tools, and the free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000. Learn about ScreenshotNeo.

Sign up for 1,000 free screenshots a month with no card.

Bottom line

For an existing Flask route that fails with ValueError: signal only works in main thread, disable Pyppeteer’s SIGINT, SIGTERM, and SIGHUP handlers in the launch() call and close the browser in finally. Change the execution model only when the work must outlive the request or the application needs a persistent async service.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.