Skip to content

Move Socket.IO initialization all the way to head + Pre-Mount Message Queueing #4756

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 11 commits into
base: main
Choose a base branch
from

Conversation

evnchn
Copy link
Collaborator

@evnchn evnchn commented May 16, 2025

This PR is a more heavy-handed approach in making sure we establish Socket.IO initialization as soon as possible. It is benchmarked to be at least 50% faster than the current implementation, with higher performance gains under more adverse network connection.

Before PR:

image

"Connected in" time > DOMContentLoaded, since we only establish Socket.IO connection after mounted

After PR:

{FA188AE4-E9B2-4588-8118-3FE6CBA9DE94}

"Connected in" time < DOMContentLoaded, since we establish Socket.IO connection as early as possible, and that occurs before Vue was loaded in at all.

Explainer:

Currently, NiceGUI loads like this:

  • Load in the CSS files in head
  • Load in the JS files in body
  • createApp
  • When the app is mounted, setup Socket.IO
  • The triple handshake (Engine.IO, Socket.IO, then NiceGUI)
  • await ui.context.client.connected() finishes

My PR change it to be like this

  • Load in the CSS files in head
  • Load in just enough JS to setup Socket.IO with a pre-mount message queue
  • The triple handshake (Engine.IO, Socket.IO, then NiceGUI)
  • await ui.context.client.connected() finishes
  • Load in the JS files in body
  • createApp
  • When the app is mounted, execute the messages stored in the message queue and re-setup Socket.IO

Since we begin talking to Socket.IO earlier, we:

  • Complete the triple-handshake earlier
  • await ui.context.client.connected() finishes earlier
  • Obtain the messages to be ran after mounted earlier

To-do items:

  • Heard pytest wasn't too happy about this. Let's fix either pytest or my code! (likely former)
  • Remove debugging console.log prints.
image

PS: #4755 change it to be like this

  • Load in the CSS files in head
  • Load in the JS files in body
  • createApp
  • When the app is beforeCreate, setup Socket.IO
  • The triple handshake occurs from here (Engine.IO, Socket.IO, then NiceGUI)
  • Assign mounted_app = this; when app is mounted only
  • await ui.context.client.connected() finishes

@evnchn evnchn changed the title Move Socket.IO initialization all the way to head + Pre-Mount Message Caching Move Socket.IO initialization all the way to head + Pre-Mount Message Queueing May 16, 2025
@evnchn
Copy link
Collaborator Author

evnchn commented May 16, 2025

Using Slow 3G to show it more clearly

Before PR:

{1413A891-8068-4467-9AB2-7CF9EFA28503}

After PR:

{D810CA9E-8F78-435D-9F51-70575E97D2B9}

Speed advantage widens when more components are loaded in:

Before PR:

{A90FA518-81D1-4368-AD59-7DFA84223096}

After PR:

{CF8A5A23-C3FE-460F-BC44-80EBDFE32002}

Benchmarking script:

from nicegui import ui
import time


async def show_connection_speed():
    ui.label('Testing connection speed...')
    time_begin = time.perf_counter()
    await ui.context.client.connected(timeout=30)
    time_end = time.perf_counter()
    ui.label('Connected in {:.6f} seconds'.format(time_end - time_begin))


@ui.page('/test_connection_speed')
async def test_connection_speed():
    await show_connection_speed()


@ui.page('/test_connection_speed2')
async def test_connection_speed2():
    ui.mermaid('''
    graph TD;
        A[Start] --> B{Is it working?};
        B -- Yes --> C[Great!];
        B -- No --> D[Try again];
        D --> B;
    ''')
    ui.select([1, 2, 3], value=2)
    await show_connection_speed()

ui.run()

However, we are re-defining what await ui.context.client.connected(timeout=30) entails.

It used to be "when the app is mounted and thus the Socket.IO is ready". Now it is "when the Socket.IO is ready, which the app may be still mounting".

Matters less since we have a pre-mount message queue which stops things like run_javascript when the DOM is empty, but user's code expectations may be changed.

Perhaps we need to introduce a flag in ui.context.client.connected to wait for the app to be mounted, which is default-True in 2.x for backwards-compatible behaviour, but potentially default-False in 3.x to encourage users sending message to the Outbox ASAP.

@evnchn evnchn added feature Type/scope: New feature or enhancement in progress Status: Someone is working on it ⚪️ minor Priority: Low impact, nice-to-have labels May 16, 2025
@evnchn
Copy link
Collaborator Author

evnchn commented May 16, 2025

Tests also completed slightly faster.

Before

{44D2F0E3-132D-4219-8ABD-A5A5E2FFAE6F}

After

{B47F85B7-D58B-41F4-A646-4190F0101222}

But not by much, since everything should be cached.

@evnchn evnchn added review Status: PR is open and needs review and removed in progress Status: Someone is working on it labels May 16, 2025
@evnchn
Copy link
Collaborator Author

evnchn commented May 16, 2025

Got some Git issues going on but I believe up to commit c5160b4 this PR contains #4754

If not I'd be sad...

@evnchn
Copy link
Collaborator Author

evnchn commented May 17, 2025

Perhaps we need to introduce a flag in ui.context.client.connected to wait for the app to be mounted, which is default-True in 2.x for backwards-compatible behaviour, but potentially default-False in 3.x to encourage users sending message to the Outbox ASAP.

No. This is because our new behavior is in-principle "impossible to distinguish from a browser which just so happens to suffer a network lag shortly after load". The transport layer is in NiceGUI's hands. We can do whatever we want.

If they need to time exactly from when the browser is fully loaded, they need await ui.context.client.connected() then await ui.run_javascript('blah') to guage when did the JavaScript actually get ran (since receiving a result is an indication that the message is not in premountMessageQueue but actually ran)

@falkoschindler falkoschindler self-requested a review May 22, 2025 06:16
@falkoschindler
Copy link
Contributor

Ok, you say this PR is 50% faster and this is basically the time it takes to await ui.context.client.connected(). But at the same time you're changing what connected() means. So I'm not yet convinced that we're getting any noticeable improvement. The client might be connected, but still can't process messages because the client is still busy loading dependencies and mounting the app. The timelines in your screenshots look pretty similar, indicating both pages being ready almost at the same time. So what is the actual benefit for the user?

@evnchn
Copy link
Collaborator Author

evnchn commented May 22, 2025

The page does indeed get load in faster, since you are in essence parallelizing your network requests instead of one-by-one.

image

Since Socket.IO handshake is actually 3 handshakes (Engine-IO, Socket-IO, and NiceGUI, check #4754 if you'd like a refresher), we incur time penalty of 3x the round-trip network latency, so it can be quite a lot, if your network is bad enough.

So, sorry but I can't agree that "timelines in your screenshots look pretty similar", and there is definitely noticeable improvement.

But my simplified timeline also shows the change: await ui.context.client.connected() will return earlier, and earlier than the browser Vue actually being ready, so messages sent after the orange block but before the yellow block will be held in a queue.

@evnchn
Copy link
Collaborator Author

evnchn commented May 22, 2025

I have an analogy.

Imagine Socket.IO is the engine in a gasoline vehicle.

#4755 is tuning the engine start sequence so that when you turn the key, the engine spins up a bit faster.

#4756 is getting someone to go downstairs and start the car for you ahead-of-time.

If that doesn't make things clear, I don't know what will...

falkoschindler pushed a commit that referenced this pull request May 27, 2025
… simultaneous bottleneck (#4798)

### Motivation

Still in the mood of speed improvement. 

So I was reading
https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script
again...

Basically, loading behaviour wise, there's 3 valid options: 


![image](https://github.com/user-attachments/assets/f00c9eae-3b58-4fba-8985-043371e3cab4)

1. Normal script
2. Defer script
3. Async script

(Don't think about defer async. It has been mentioned that if async is
set, ["If the attribute is specified with the defer attribute, the
element will act as if only the async attribute is
specified"](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script#:~:text=If%20the%20attribute%20is%20specified%20with%20the%20defer%20attribute%2C%20the%20element%20will%20act%20as%20if%20only%20the%20async%20attribute%20is%20specified),
meaning async + defer => async).

I then took a look at the script situation in NiceGUI: 

1. No script in head
2. Body's empty, no elements
3. Main script is a `<script type="module">`, which is ["defer by
default"](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script#:~:text=scripts%20%E2%80%94%20they-,defer%20by%20default,-.)

Then I thought: **Can I have the rest of the NiceGUI's scripts be defer
as well, since they need to run before the main script only, and not any
earlier. We don't need to block up the parser while those scripts
load?**

And yes, indeed, there is performance uplift.  

### Implementation

- Change the scripts from normal scripts to defer scripts. 
- Left the ES module shim in-tact since that probably has to load ASAP.
- Left Socket.IO in-tact because we'd have to move it anyways if we
adapt #4756
- Both instances, maybe `defer` would make it even faster, or it would
break things. I'll see when I have time.

### Progress

- [x] I chose a meaningful title that completes the sentence: "If
applied, this PR will..."
- [x] The implementation is complete.
- [x] Pytests have been added (or are not necessary).
- [x] Documentation has been added (or is not necessary).

-> No need pytest and documentation. As long as existing tests don't
break, Id be happy

### Results showcase

Using Fast 4G profile, 4x CPU slowdown. 

Before: 


![image](https://github.com/user-attachments/assets/33218a83-f4c6-4033-bab2-0e4eb3de0501)

After: 


![image](https://github.com/user-attachments/assets/365557c0-9b61-43c7-8ead-76092ce9b5ff)

Finish: 6.47s -> 5.99s
DOMContentLoaded: 4.69s -> 4.38s
Load: 5.69s -> 5.18s

About save 5s. 

Reference: 4G network latency 165ms. I am not saying that we are saving
3x the network latency, but it just puts things into perspective.

Worthy of note: 

- _No performance uplift_ if network is not slowed down and only CPU
slowdown, since the network request would complete in an instant, and we
didn't really block the parser for much.
- _No performance uplift_ if CPU is not slowed down and only network
slowdown, since the parsing would complete in an instant, and we can't
block the parser if completes in a blink of the eye.
- **Only notice performance uplift** if you apply BOTH network slowdown
and CPU slowdown.

May want to test with more slowdown and Slow 4G later. 3G's too much.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature Type/scope: New feature or enhancement ⚪️ minor Priority: Low impact, nice-to-have review Status: PR is open and needs review
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants