diff --git a/analysis_options.yaml b/analysis_options.yaml
index 0d29021..1ff481e 100644
--- a/analysis_options.yaml
+++ b/analysis_options.yaml
@@ -9,6 +9,13 @@
 # packages, and plugins designed to encourage good coding practices.
 include: package:flutter_lints/flutter.yaml
 
+analyzer:
+  exclude:
+    - "**/*.g.dart"
+    - "**/*.freezed.dart"
+  errors:
+    invalid_annotation_target: ignore # Due to freezed + json_serializable issue, ref https://github.com/rrousselGit/freezed/issues/488#issuecomment-894358980
+
 linter:
   # The lint rules applied to this project can be customized in the
   # section below to disable rules from the `package:flutter_lints/flutter.yaml`
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index 374309d..60dafd4 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -43,7 +43,7 @@ PODS:
   - Flutter (1.0.0)
   - flutter_native_splash (0.0.1):
     - Flutter
-  - flutter_secure_storage (3.3.1):
+  - flutter_secure_storage (6.0.0):
     - Flutter
   - image_picker_ios (0.0.1):
     - Flutter
@@ -119,7 +119,7 @@ SPEC CHECKSUMS:
   file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
   Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
   flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778
-  flutter_secure_storage: 7953c38a04c3fdbb00571bcd87d8e3b5ceb9daec
+  flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
   image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
   path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
   SDWebImage: 8a6b7b160b4d710e2a22b6900e25301075c34cb3
diff --git a/lib/main.dart b/lib/main.dart
index a1193ac..4201c0b 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -11,6 +11,7 @@ import 'package:surface/providers/sn_attachment.dart';
 import 'package:surface/providers/sn_network.dart';
 import 'package:surface/providers/theme.dart';
 import 'package:surface/providers/userinfo.dart';
+import 'package:surface/providers/websocket.dart';
 import 'package:surface/router.dart';
 
 void main() async {
@@ -39,11 +40,15 @@ class SolianApp extends StatelessWidget {
         assetLoader: JsonAssetLoader(),
         child: MultiProvider(
           providers: [
+            // Display layer
+            ChangeNotifierProvider(create: (_) => ThemeProvider()),
+            ChangeNotifierProvider(create: (ctx) => NavigationProvider()),
+
+            // Data layer
             Provider(create: (_) => SnNetworkProvider()),
             Provider(create: (ctx) => SnAttachmentProvider(ctx)),
-            ChangeNotifierProvider(create: (ctx) => NavigationProvider()),
             ChangeNotifierProvider(create: (ctx) => UserProvider(ctx)),
-            ChangeNotifierProvider(create: (_) => ThemeProvider()),
+            ChangeNotifierProvider(create: (ctx) => WebSocketProvider(ctx)),
           ],
           child: AppMainContent(),
         ),
@@ -63,7 +68,7 @@ class AppMainContent extends StatelessWidget {
   @override
   Widget build(BuildContext context) {
     context.read<NavigationProvider>();
-    context.read<UserProvider>();
+    context.read<WebSocketProvider>();
 
     final th = context.watch<ThemeProvider>();
 
diff --git a/lib/providers/sn_network.dart b/lib/providers/sn_network.dart
index cfbd7a7..bcdea9e 100644
--- a/lib/providers/sn_network.dart
+++ b/lib/providers/sn_network.dart
@@ -44,48 +44,11 @@ class SnNetworkProvider {
           RequestOptions options,
           RequestInterceptorHandler handler,
         ) async {
-          try {
-            var atk = await _storage.read(key: kAtkStoreKey);
-            if (atk != null) {
-              final atkParts = atk.split('.');
-              if (atkParts.length != 3) {
-                throw Exception('invalid format of access token');
-              }
-
-              var rawPayload =
-                  atkParts[1].replaceAll('-', '+').replaceAll('_', '/');
-              switch (rawPayload.length % 4) {
-                case 0:
-                  break;
-                case 2:
-                  rawPayload += '==';
-                  break;
-                case 3:
-                  rawPayload += '=';
-                  break;
-                default:
-                  throw Exception('illegal format of access token payload');
-              }
-
-              final b64 = utf8.fuse(base64Url);
-              final payload = b64.decode(rawPayload);
-              final exp = jsonDecode(payload)['exp'];
-              if (exp <= DateTime.now().millisecondsSinceEpoch ~/ 1000) {
-                log('Access token need refresh, doing it at ${DateTime.now()}');
-                atk = await refreshToken();
-              }
-
-              if (atk != null) {
-                options.headers['Authorization'] = 'Bearer $atk';
-              } else {
-                log('Access token refresh failed...');
-              }
-            }
-          } catch (err) {
-            log('Failed to authenticate user: $err');
-          } finally {
-            handler.next(options);
+          final atk = await getFreshAtk();
+          if (atk != null) {
+            options.headers['Authorization'] = 'Bearer $atk';
           }
+          return handler.next(options);
         },
       ),
     );
@@ -99,6 +62,50 @@ class SnNetworkProvider {
     });
   }
 
+  Future<String?> getFreshAtk() async {
+    try {
+      var atk = await _storage.read(key: kAtkStoreKey);
+      if (atk != null) {
+        final atkParts = atk.split('.');
+        if (atkParts.length != 3) {
+          throw Exception('invalid format of access token');
+        }
+
+        var rawPayload = atkParts[1].replaceAll('-', '+').replaceAll('_', '/');
+        switch (rawPayload.length % 4) {
+          case 0:
+            break;
+          case 2:
+            rawPayload += '==';
+            break;
+          case 3:
+            rawPayload += '=';
+            break;
+          default:
+            throw Exception('illegal format of access token payload');
+        }
+
+        final b64 = utf8.fuse(base64Url);
+        final payload = b64.decode(rawPayload);
+        final exp = jsonDecode(payload)['exp'];
+        if (exp <= DateTime.now().millisecondsSinceEpoch ~/ 1000) {
+          log('Access token need refresh, doing it at ${DateTime.now()}');
+          atk = await refreshToken();
+        }
+
+        if (atk != null) {
+          return atk;
+        } else {
+          log('Access token refresh failed...');
+        }
+      }
+    } catch (err) {
+      log('Failed to authenticate user: $err');
+    }
+
+    return null;
+  }
+
   String getAttachmentUrl(String ky) {
     if (ky.startsWith("http")) return ky;
     return '${client.options.baseUrl}/cgi/uc/attachments/$ky';
diff --git a/lib/providers/userinfo.dart b/lib/providers/userinfo.dart
index 607ec52..c0b1597 100644
--- a/lib/providers/userinfo.dart
+++ b/lib/providers/userinfo.dart
@@ -13,6 +13,8 @@ class UserProvider extends ChangeNotifier {
   late final SnNetworkProvider _sn;
   late final FlutterSecureStorage _storage = FlutterSecureStorage();
 
+  Future<String?> get atk => _storage.read(key: kAtkStoreKey);
+
   UserProvider(BuildContext context) {
     _sn = context.read<SnNetworkProvider>();
 
diff --git a/lib/providers/websocket.dart b/lib/providers/websocket.dart
new file mode 100644
index 0000000..07b1d05
--- /dev/null
+++ b/lib/providers/websocket.dart
@@ -0,0 +1,109 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:developer';
+
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+import 'package:surface/providers/sn_network.dart';
+import 'package:surface/providers/userinfo.dart';
+import 'package:surface/types/websocket.dart';
+import 'package:web_socket_channel/web_socket_channel.dart';
+
+class WebSocketProvider extends ChangeNotifier {
+  bool isBusy = false;
+  bool isConnected = false;
+
+  WebSocketChannel? conn;
+
+  late final SnNetworkProvider _sn;
+  late final UserProvider _ua;
+
+  StreamController<WebSocketPackage> stream = StreamController.broadcast();
+
+  WebSocketProvider(BuildContext context) {
+    _sn = context.read<SnNetworkProvider>();
+    _ua = context.read<UserProvider>();
+
+    // Wait for the userinfo provide initialize authorization status
+    Future.delayed(const Duration(milliseconds: 250), () async {
+      if (_ua.isAuthorized) {
+        log('[WebSocket] Connecting to the server...');
+        await connect();
+      } else {
+        log('[WebSocket] Unable connect to the server, unauthorized.');
+      }
+    });
+  }
+
+  Future<void> connect({noRetry = false}) async {
+    if (!_ua.isAuthorized) return;
+    if (isConnected) {
+      disconnect();
+    }
+
+    final atk = await _sn.getFreshAtk();
+    final uri = Uri.parse(
+      '${_sn.client.options.baseUrl.replaceFirst('http', 'ws')}/ws?tk=$atk',
+    );
+
+    isBusy = true;
+    notifyListeners();
+
+    try {
+      conn = WebSocketChannel.connect(uri);
+      await conn!.ready;
+      log('[WebSocket] Connected to server!');
+    } catch (err) {
+      if (err is WebSocketChannelException) {
+        log('Failed to connect to websocket: ${(err.inner as dynamic).message}');
+      } else {
+        log('Failed to connect to websocket: $err');
+      }
+
+      if (!noRetry) {
+        log('Retry connecting to websocket in 3 seconds...');
+        return Future.delayed(
+          const Duration(seconds: 3),
+          () => connect(noRetry: true),
+        );
+      }
+    } finally {
+      isBusy = false;
+      notifyListeners();
+    }
+  }
+
+  void disconnect() {
+    if (conn != null) {
+      conn!.sink.close();
+    }
+    isConnected = false;
+    notifyListeners();
+  }
+
+  void listen() {
+    conn?.stream.listen(
+      (event) {
+        final packet = WebSocketPackage.fromJson(jsonDecode(event));
+        log('Websocket incoming message: ${packet.method} ${packet.message}');
+        stream.sink.add(packet);
+        // TODO handle notification
+        // if (packet.method == 'notifications.new') {
+        //   final NotificationProvider nty = Get.find();
+        //   nty.notifications.add(Notification.fromJson(packet.payload!));
+        //   nty.notificationUnread.value++;
+        // }
+      },
+      onDone: () {
+        isConnected = false;
+        notifyListeners();
+        Future.delayed(const Duration(seconds: 1), () => connect());
+      },
+      onError: (err) {
+        isConnected = false;
+        notifyListeners();
+        Future.delayed(const Duration(seconds: 11), () => connect());
+      },
+    );
+  }
+}
diff --git a/lib/types/websocket.dart b/lib/types/websocket.dart
new file mode 100644
index 0000000..481500c
--- /dev/null
+++ b/lib/types/websocket.dart
@@ -0,0 +1,17 @@
+import 'package:freezed_annotation/freezed_annotation.dart';
+
+part 'websocket.freezed.dart';
+part 'websocket.g.dart';
+
+@freezed
+class WebSocketPackage with _$WebSocketPackage {
+  const factory WebSocketPackage({
+    @JsonKey(name: 'w') @Default('unknown') String method,
+    @JsonKey(name: 'e') String? endpoint,
+    @JsonKey(name: 'm') String? message,
+    @JsonKey(name: 'p') @Default({}) Map<String, dynamic>? payload,
+  }) = _WebSocketPackage;
+
+  factory WebSocketPackage.fromJson(Map<String, dynamic> json) =>
+      _$WebSocketPackageFromJson(json);
+}
diff --git a/lib/types/websocket.freezed.dart b/lib/types/websocket.freezed.dart
new file mode 100644
index 0000000..246e455
--- /dev/null
+++ b/lib/types/websocket.freezed.dart
@@ -0,0 +1,252 @@
+// coverage:ignore-file
+// GENERATED CODE - DO NOT MODIFY BY HAND
+// ignore_for_file: type=lint
+// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
+
+part of 'websocket.dart';
+
+// **************************************************************************
+// FreezedGenerator
+// **************************************************************************
+
+T _$identity<T>(T value) => value;
+
+final _privateConstructorUsedError = UnsupportedError(
+    'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
+
+WebSocketPackage _$WebSocketPackageFromJson(Map<String, dynamic> json) {
+  return _WebSocketPackage.fromJson(json);
+}
+
+/// @nodoc
+mixin _$WebSocketPackage {
+  @JsonKey(name: 'w')
+  String get method => throw _privateConstructorUsedError;
+  @JsonKey(name: 'e')
+  String? get endpoint => throw _privateConstructorUsedError;
+  @JsonKey(name: 'm')
+  String? get message => throw _privateConstructorUsedError;
+  @JsonKey(name: 'p')
+  Map<String, dynamic>? get payload => throw _privateConstructorUsedError;
+
+  /// Serializes this WebSocketPackage to a JSON map.
+  Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
+
+  /// Create a copy of WebSocketPackage
+  /// with the given fields replaced by the non-null parameter values.
+  @JsonKey(includeFromJson: false, includeToJson: false)
+  $WebSocketPackageCopyWith<WebSocketPackage> get copyWith =>
+      throw _privateConstructorUsedError;
+}
+
+/// @nodoc
+abstract class $WebSocketPackageCopyWith<$Res> {
+  factory $WebSocketPackageCopyWith(
+          WebSocketPackage value, $Res Function(WebSocketPackage) then) =
+      _$WebSocketPackageCopyWithImpl<$Res, WebSocketPackage>;
+  @useResult
+  $Res call(
+      {@JsonKey(name: 'w') String method,
+      @JsonKey(name: 'e') String? endpoint,
+      @JsonKey(name: 'm') String? message,
+      @JsonKey(name: 'p') Map<String, dynamic>? payload});
+}
+
+/// @nodoc
+class _$WebSocketPackageCopyWithImpl<$Res, $Val extends WebSocketPackage>
+    implements $WebSocketPackageCopyWith<$Res> {
+  _$WebSocketPackageCopyWithImpl(this._value, this._then);
+
+  // ignore: unused_field
+  final $Val _value;
+  // ignore: unused_field
+  final $Res Function($Val) _then;
+
+  /// Create a copy of WebSocketPackage
+  /// with the given fields replaced by the non-null parameter values.
+  @pragma('vm:prefer-inline')
+  @override
+  $Res call({
+    Object? method = null,
+    Object? endpoint = freezed,
+    Object? message = freezed,
+    Object? payload = freezed,
+  }) {
+    return _then(_value.copyWith(
+      method: null == method
+          ? _value.method
+          : method // ignore: cast_nullable_to_non_nullable
+              as String,
+      endpoint: freezed == endpoint
+          ? _value.endpoint
+          : endpoint // ignore: cast_nullable_to_non_nullable
+              as String?,
+      message: freezed == message
+          ? _value.message
+          : message // ignore: cast_nullable_to_non_nullable
+              as String?,
+      payload: freezed == payload
+          ? _value.payload
+          : payload // ignore: cast_nullable_to_non_nullable
+              as Map<String, dynamic>?,
+    ) as $Val);
+  }
+}
+
+/// @nodoc
+abstract class _$$WebSocketPackageImplCopyWith<$Res>
+    implements $WebSocketPackageCopyWith<$Res> {
+  factory _$$WebSocketPackageImplCopyWith(_$WebSocketPackageImpl value,
+          $Res Function(_$WebSocketPackageImpl) then) =
+      __$$WebSocketPackageImplCopyWithImpl<$Res>;
+  @override
+  @useResult
+  $Res call(
+      {@JsonKey(name: 'w') String method,
+      @JsonKey(name: 'e') String? endpoint,
+      @JsonKey(name: 'm') String? message,
+      @JsonKey(name: 'p') Map<String, dynamic>? payload});
+}
+
+/// @nodoc
+class __$$WebSocketPackageImplCopyWithImpl<$Res>
+    extends _$WebSocketPackageCopyWithImpl<$Res, _$WebSocketPackageImpl>
+    implements _$$WebSocketPackageImplCopyWith<$Res> {
+  __$$WebSocketPackageImplCopyWithImpl(_$WebSocketPackageImpl _value,
+      $Res Function(_$WebSocketPackageImpl) _then)
+      : super(_value, _then);
+
+  /// Create a copy of WebSocketPackage
+  /// with the given fields replaced by the non-null parameter values.
+  @pragma('vm:prefer-inline')
+  @override
+  $Res call({
+    Object? method = null,
+    Object? endpoint = freezed,
+    Object? message = freezed,
+    Object? payload = freezed,
+  }) {
+    return _then(_$WebSocketPackageImpl(
+      method: null == method
+          ? _value.method
+          : method // ignore: cast_nullable_to_non_nullable
+              as String,
+      endpoint: freezed == endpoint
+          ? _value.endpoint
+          : endpoint // ignore: cast_nullable_to_non_nullable
+              as String?,
+      message: freezed == message
+          ? _value.message
+          : message // ignore: cast_nullable_to_non_nullable
+              as String?,
+      payload: freezed == payload
+          ? _value._payload
+          : payload // ignore: cast_nullable_to_non_nullable
+              as Map<String, dynamic>?,
+    ));
+  }
+}
+
+/// @nodoc
+@JsonSerializable()
+class _$WebSocketPackageImpl implements _WebSocketPackage {
+  const _$WebSocketPackageImpl(
+      {@JsonKey(name: 'w') this.method = 'unknown',
+      @JsonKey(name: 'e') this.endpoint,
+      @JsonKey(name: 'm') this.message,
+      @JsonKey(name: 'p') final Map<String, dynamic>? payload = const {}})
+      : _payload = payload;
+
+  factory _$WebSocketPackageImpl.fromJson(Map<String, dynamic> json) =>
+      _$$WebSocketPackageImplFromJson(json);
+
+  @override
+  @JsonKey(name: 'w')
+  final String method;
+  @override
+  @JsonKey(name: 'e')
+  final String? endpoint;
+  @override
+  @JsonKey(name: 'm')
+  final String? message;
+  final Map<String, dynamic>? _payload;
+  @override
+  @JsonKey(name: 'p')
+  Map<String, dynamic>? get payload {
+    final value = _payload;
+    if (value == null) return null;
+    if (_payload is EqualUnmodifiableMapView) return _payload;
+    // ignore: implicit_dynamic_type
+    return EqualUnmodifiableMapView(value);
+  }
+
+  @override
+  String toString() {
+    return 'WebSocketPackage(method: $method, endpoint: $endpoint, message: $message, payload: $payload)';
+  }
+
+  @override
+  bool operator ==(Object other) {
+    return identical(this, other) ||
+        (other.runtimeType == runtimeType &&
+            other is _$WebSocketPackageImpl &&
+            (identical(other.method, method) || other.method == method) &&
+            (identical(other.endpoint, endpoint) ||
+                other.endpoint == endpoint) &&
+            (identical(other.message, message) || other.message == message) &&
+            const DeepCollectionEquality().equals(other._payload, _payload));
+  }
+
+  @JsonKey(includeFromJson: false, includeToJson: false)
+  @override
+  int get hashCode => Object.hash(runtimeType, method, endpoint, message,
+      const DeepCollectionEquality().hash(_payload));
+
+  /// Create a copy of WebSocketPackage
+  /// with the given fields replaced by the non-null parameter values.
+  @JsonKey(includeFromJson: false, includeToJson: false)
+  @override
+  @pragma('vm:prefer-inline')
+  _$$WebSocketPackageImplCopyWith<_$WebSocketPackageImpl> get copyWith =>
+      __$$WebSocketPackageImplCopyWithImpl<_$WebSocketPackageImpl>(
+          this, _$identity);
+
+  @override
+  Map<String, dynamic> toJson() {
+    return _$$WebSocketPackageImplToJson(
+      this,
+    );
+  }
+}
+
+abstract class _WebSocketPackage implements WebSocketPackage {
+  const factory _WebSocketPackage(
+          {@JsonKey(name: 'w') final String method,
+          @JsonKey(name: 'e') final String? endpoint,
+          @JsonKey(name: 'm') final String? message,
+          @JsonKey(name: 'p') final Map<String, dynamic>? payload}) =
+      _$WebSocketPackageImpl;
+
+  factory _WebSocketPackage.fromJson(Map<String, dynamic> json) =
+      _$WebSocketPackageImpl.fromJson;
+
+  @override
+  @JsonKey(name: 'w')
+  String get method;
+  @override
+  @JsonKey(name: 'e')
+  String? get endpoint;
+  @override
+  @JsonKey(name: 'm')
+  String? get message;
+  @override
+  @JsonKey(name: 'p')
+  Map<String, dynamic>? get payload;
+
+  /// Create a copy of WebSocketPackage
+  /// with the given fields replaced by the non-null parameter values.
+  @override
+  @JsonKey(includeFromJson: false, includeToJson: false)
+  _$$WebSocketPackageImplCopyWith<_$WebSocketPackageImpl> get copyWith =>
+      throw _privateConstructorUsedError;
+}
diff --git a/lib/types/websocket.g.dart b/lib/types/websocket.g.dart
new file mode 100644
index 0000000..c1e3b09
--- /dev/null
+++ b/lib/types/websocket.g.dart
@@ -0,0 +1,25 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+part of 'websocket.dart';
+
+// **************************************************************************
+// JsonSerializableGenerator
+// **************************************************************************
+
+_$WebSocketPackageImpl _$$WebSocketPackageImplFromJson(
+        Map<String, dynamic> json) =>
+    _$WebSocketPackageImpl(
+      method: json['w'] as String? ?? 'unknown',
+      endpoint: json['e'] as String?,
+      message: json['m'] as String?,
+      payload: json['p'] as Map<String, dynamic>? ?? const {},
+    );
+
+Map<String, dynamic> _$$WebSocketPackageImplToJson(
+        _$WebSocketPackageImpl instance) =>
+    <String, dynamic>{
+      'w': instance.method,
+      'e': instance.endpoint,
+      'm': instance.message,
+      'p': instance.payload,
+    };
diff --git a/pubspec.lock b/pubspec.lock
index 500982e..6a5a7b5 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -1480,7 +1480,7 @@ packages:
     source: hosted
     version: "0.1.6"
   web_socket_channel:
-    dependency: transitive
+    dependency: "direct main"
     description:
       name: web_socket_channel
       sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f"
diff --git a/pubspec.yaml b/pubspec.yaml
index d164078..d4df94d 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -73,6 +73,7 @@ dependencies:
   path_provider: ^2.1.5
   collection: ^1.18.0
   mime: ^2.0.0
+  web_socket_channel: ^3.0.1
 
 dev_dependencies:
   flutter_test: