WebSocket using PAGI::FastAPI

4 min read
← Back to Blogs
WebSocket using PAGI::FastAPI

DISCLAIMER: Image is generated using ChatGPT.



For last week or so, I have been playing with PAGI::FastAPI. This is my latest creation also my first attempt in creating async micro web framework.

I have been, Dancer2 fan all my life. It has done all I wanted without any extra effort.

While going through my old notes, I came across Python’s FastAPI. I decided to port it to Perl. Using PAGI, the job was half done already.

I released the first draft on Aug 3, 2026. After the first release, I started looking ways to integrate DBIx::Class::Async. It was obvious choice when creating application with database. It helped me in integration as I am the creator of DBIx::Class::Async.

The best part was, no code change was needed in PAGI::FastAPI. I quickly had a demo application up and running. Just to record the process, I created a blog post.

What next?

In Feb, 2026, I created blog post about creating chat server using websocket. When I shared the post on social media, I got suggestion to re-create using Redis and PostgreSQL. At that point in time, I decided to create GitHub repository so that I can keep track of different implementations.

Having done that, I didn’t stop, I then re-create the chat server using PAGI and Thunderhorse. You can find working codes in the repository.

I started exploring ways to add the support of WebSocket to the PAGI::FastAPI. The idea was to create chat server using PAGI::FastAPI after that.

I really had to fight hard but eventually got what I wanted and released v0.0.6 with websocket support. I released the distribution with simple example using websocket.

You can try sample chat:

$ pagi-server websocket_app.pl

In the browser, visit http://localhost:5000 and start chatting.

My end goal was to re-create the original chat server using PAGI::FastAPI. Looking at the GitHub repository, I had to start the PostgreSQL database in docker container as other implementations also used the same.

$ docker compose up -d

Finally, I have a working example using PAGI::FastAPI.

Let me share the excerpts:

Skeleton

my $app = PAGI::FastAPI->new(
    title   => 'PAGI::FastAPI Chat Server',
    version => '1.0.0',
);

Setup Lifespan Protocol

$app->on_startup(async sub {
    print "Server starting up...\n";

    await pg_query(q{
        CREATE TABLE IF NOT EXISTS chat_users (
            session_id TEXT PRIMARY KEY,
            username   TEXT NOT NULL,
            last_seen  TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        )
    });

    # Stale-user cleanup, every 60s. Same Future::IO approach as the
    # heartbeat above, started here, stopped via the $shutting_down flag
    # in on_shutdown rather than an explicit timer object.
    cleanup_loop()->retain;

    # Cross-process fan-out: other server processes publish here too, so a
    # message sent to the server on :3000 reaches clients connected to :3001.
    $pubsub->listen('chat_messages' => sub {
        my ($pubsub, $payload) = @_;

        my $data = ref($payload) eq 'HASH'
            ? $payload
            : eval { decode_json($payload) };
        return unless $data;

        my $from_process = delete $data->{_process_id};
        return if defined $from_process && $from_process eq $process_id;

        if ($data->{type} eq 'message') {
            push @history, $data;
            shift @history if @history > 10;
        }

        foreach my $client (values %$clients) {
            eval { $client->{ws}->send_json($data) };
        }
    });
});
$app->on_shutdown(async sub {
    print "Server shutting down...\n";

    $shutting_down = 1;

    # Instantly wake every heartbeat/cleanup coroutine currently sleeping,
    # instead of leaving them to notice $shutting_down up to 30-60s later.
    for my $sleep (values %active_sleeps) {
        $sleep->cancel unless $sleep->is_ready;
    }

    # Cancelling sleeps stops *new*  queries from starting, but doesn't
    # touch a query already sent to Postgres, wait (up to 5s) for those
    # to actually get a response and  resolve their Future normally, so
    # we don't disconnect out from under one and risk the same class of
    # use-after-free during global destruction.
    await wait_for_inflight_queries();

    eval { $pubsub->unlisten('chat_messages') };
    eval { $pg->db->disconnect };
});

Having done that, finally looking at the core:

$app->websocket('/chat',
    handler => async sub ($ws, $deps) {
        await $ws->accept;

        my $id = "$ws";
        $clients->{$id} = { ws => $ws, name => 'Anonymous' };

        # Heartbeat: keeps this connection's last_seen fresh in Postgres
        # for cross-process presence. Future::IO->sleep is loop-agnostic,
        # no IO::Async::Loop object of our own is needed, since it delegates
        # to whatever Future::IO backend is already configured (PAGI::Server
        # provides one). The loop naturally stops once $clients->{$id} is
        # deleted on disconnect below, no explicit timer teardown needed.
        heartbeat_loop($id)->else(sub { Future->done })->retain;

        # Handle incoming messages
        while (1) {
            my $msg_text = await $ws->receive_text;
            last unless defined $msg_text;

            my $data = eval { decode_json($msg_text) };
            next unless $data;

            if ($data->{type} eq 'typing') {
                await broadcast({
                    type     => 'typing',
                    user     => $clients->{$id}{name},
                    isTyping => $data->{isTyping} ? 1 : 0
                }, $id);
            }
            elsif ($data->{type} eq 'join') {
                $clients->{$id}{name} = $data->{name};

                await pg_query(q{
                    INSERT INTO chat_users (session_id, username, last_seen)
                    VALUES (?, ?, NOW())
                    ON CONFLICT (session_id)
                    DO UPDATE SET username  = EXCLUDED.username,
                                  last_seen = NOW()
                }, $id, $data->{name});

                # Send history to new user
                foreach my $old_msg (@history) {
                    await $ws->send_text(encode_json($old_msg));
                }

                await broadcast({
                    type => 'system',
                    text => "$data->{name} joined"
                });
                await send_user_list();
            }
            elsif ($data->{type} eq 'message') {
                my (undef, $min, $hour) = localtime();
                my $timestamp = sprintf("%02d:%02d", $hour, $min);
                my $msg_out = {
                    type      => 'message',
                    user      => $clients->{$id}{name},
                    text      => $data->{text},
                    timestamp => $timestamp
                };

                push @history, $msg_out;
                shift @history if @history > 10;

                await broadcast($msg_out);
            }
        }

        # Cleanup on disconnect
        my $name = $clients->{$id}{name};
        delete $clients->{$id};
        await pg_query(q{DELETE FROM chat_users WHERE session_id = ?}, $id);
        await broadcast({ type => 'system', text => "$name left" });
        await send_user_list();
    }
);

Now run the complete chat server:

$ IO_ASYNC_LOOP=EV pagi-server chat-server-v6.pl
Future::IO configured for IO::Async
PAGI development mode - Lint middleware enabled
Server starting up...
access_log is a terminal; this may impact performance. Consider redirecting to a file or setting access_log => undef for benchmarks.
PAGI Server listening on http://127.0.0.1:5000/ (loop: EV, max_conn: 1000, http2: not installed, tls: not installed, future_xs: available)

Open the browser and visit http://localhost:5000



Happy Hacking !!!