pythondjangoasync-await

How to load chat messages in batches in Django using StreamingHttpResponse?


I'm working on a Django project that streams chat messages between two users using StreamingHttpResponse and async functions. I want to load messages in batches (e.g 20 at a time) instead of loading all the messages at once to optimize performance and reduce initial load time.

Here’s my current view code:

async def stream_chat_messages(request, recipient_id: int) -> StreamingHttpResponse:
"""View used to stream chat messages between the authenticated user and a specified recipient."""
recipient = await sync_to_async(get_object_or_404)(User, id=recipient_id)

async def event_stream():
    async for message in get_existing_messages(request.user, recipient):
        yield message

    last_id = await get_last_message_id(request.user, recipient)
    while True:
        new_messages = (
            ChatMessage.objects.filter(
                Q(sender=request.user, recipient=recipient)
                | Q(sender=recipient, recipient=request.user),
                id__gt=last_id,
            )
            .annotate(
                profile_picture_url=Concat(
                    Value(settings.MEDIA_URL),
                    F("sender__userprofile__profile_picture"),
                    output_field=CharField(),
                ),
                is_pinned=Q(pinned_by__in=[request.user]),
            )
            .order_by("created_at")
            .values(
                "id",
                "created_at",
                "content",
                "profile_picture_url",
                "sender__id",
                "edited",
                "file",
                "file_size",
                "is_pinned",
            )
        )

        async for message in new_messages:
            message["created_at"] = message["created_at"].isoformat()
            message["content"] = escape(message["content"])
            json_message = json.dumps(message, cls=DjangoJSONEncoder)

            yield f"data: {json_message}\n\n"
            last_id = message["id"]
        await asyncio.sleep(0.1)

async def get_existing_messages(user, recipient) -> AsyncGenerator:
    messages = (
        ChatMessage.objects.filter(
            Q(sender=user, recipient=recipient)
            | Q(sender=recipient, recipient=user)
        )
        .filter(
            (Q(sender=user) & Q(sender_hidden=False))
            | (Q(recipient=user) & Q(recipient_hidden=False))
        )
        .annotate(
            profile_picture_url=Concat(
                Value(settings.MEDIA_URL),
                F("sender__userprofile__profile_picture"),
                output_field=CharField(),
            ),
            is_pinned=Q(pinned_by__in=[user]),
        )
        .order_by("created_at")
        .values(
            "id",
            "created_at",
            "content",
            "profile_picture_url",
            "sender__id",
            "edited",
            "file",
            "file_size",
            "is_pinned",
        )
    )

    async for message in messages:
        message["created_at"] = message["created_at"].isoformat()
        message["content"] = escape(message["content"])
        json_message = json.dumps(message, cls=DjangoJSONEncoder)

        yield f"data: {json_message}\n\n"

async def get_last_message_id(user, recipient) -> int:
    last_message = await ChatMessage.objects.filter(
        Q(sender=user, recipient=recipient) | Q(sender=recipient, recipient=user)
    ).alast()
    return last_message.id if last_message else 0

return StreamingHttpResponse(event_stream(), content_type="text/event-stream")

How can I modify this code to initially load the latest 20 messages, and then load more messages (in batches of 20) when the user clicks a "Load more" button?

Any advice or examples on how to handle this efficiently would be greatly appreciated.


Solution

  • This is not what a StreamingHttpResponse is supposed to do. A StreamingHttpResponse is typically used for large responses, think for example about file downloads, or data dumps where not everything is stored in memory at the same time.

    But typically the idea of a StreamingHttpResponse is that the client is interested in the entire content, although it might not be able to store the entire content in memory, or process all the content at once.

    But your "Load more" button is not per se clicked: one will very likely only load the first 20-60 messages anyway. In that case multiple HTTP requests are made. It is essentially just pagination [Django-doc], except that the user does not really specifies the page, but typically some (small) JavaScript does.

    Indeed, the JavaScript library thus makes a HTTP request, for example through AJAX to fetch the first ~20 elements, and when the user clicks the "Load more" button, it makes an extra AJAX request requesting the next "page" it then renders in the DOM.

    If you for example use the Django REST framework, you can work with one of the already built-in paginators [drf-doc] where you can use querystrings, the request body, or headers to specify the page, or the offset and limit.