PAGI::FastAPI + WebSocket

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

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";

    $cleanup_timer = IO::Async::Timer::Periodic->new(
        interval => 60,
        on_tick  => sub {
            eval {
                $pg->db->query(q{
                    DELETE FROM chat_users
                    WHERE last_seen < NOW() - INTERVAL '2 minutes'
                });
            };
        }
    );
    $loop->add($cleanup_timer);
    $cleanup_timer->start;

    # 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";

    $cleanup_timer->stop if $cleanup_timer;
    $loop->remove($cleanup_timer) if $cleanup_timer;
    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 timer (30 seconds)
        my $heartbeat_timer = IO::Async::Timer::Periodic->new(
            interval => 30,
            on_tick  => sub {
                return unless $clients->{$id};
                eval {
                    $pg->db->query(q{
                        UPDATE chat_users
                        SET last_seen = NOW()
                        WHERE session_id = ?
                    }, $id);
                };
            }
        );
        $loop->add($heartbeat_timer);
        $heartbeat_timer->start;

        # 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};

                $pg->db->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};
        $heartbeat_timer->stop;
        $loop->remove($heartbeat_timer);
        $pg->db->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:

$ 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: Poll, max_conn: 1000, http2: not installed, tls: not installed, future_xs: available)

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



Happy Hacking !!!