HardКейс18 min

Кейс: Instagram Feed

Проектирование ленты: infinite scroll, image optimization, lazy loading, virtual scrolling

Задача

Спроектировать систему отображения ленты (feed) для приложения с характеристиками Instagram: бесконечная прокрутка, оптимизация изображений, lazy loading и высокая производительность на мобильных устройствах.

Требования

Требование Значение
FCP < 1.5s
INP < 100ms
Scroll jank 0 (60fps)
Image load Progressive, <200ms видимой задержки
Data Cursor-based pagination
Offline Кеш последних 50 постов
Memory < 150MB при длительном скролле

Архитектура Backend API

Feed API

<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;

final readonly class FeedController
{
    public function __construct(
        private FeedService $feedService,
        private ImageService $imageService,
    ) {}

    /**
     * Get personalized feed with cursor-based pagination.
     */
    #[Route('/api/v1/feed', methods: ['GET'])]
    public function getFeed(Request $request): JsonResponse
    {
        $userId = $request->attributes->get('auth_user_id');
        $cursor = $request->query->get('cursor');
        $limit = min((int) $request->query->get('limit', '10'), 20);

        $result = $this->feedService->getFeed(
            userId: $userId,
            cursor: $cursor,
            limit: $limit,
        );

        $posts = array_map(
            fn(Post $post) => $this->formatPost($post),
            $result->posts,
        );

        $response = new JsonResponse([
            'posts' => $posts,
            'pagination' => [
                'next_cursor' => $result->nextCursor,
                'has_more' => $result->hasMore,
            ],
        ]);

        // Short cache for feed — user sees fresh content
        $response->headers->set('Cache-Control', 'private, max-age=30');
        // ETag for conditional requests
        $response->setEtag(md5(json_encode($posts)));

        return $response;
    }

    private function formatPost(Post $post): array
    {
        return [
            'id' => $post->getId(),
            'author' => [
                'id' => $post->getAuthor()->getId(),
                'username' => $post->getAuthor()->getUsername(),
                'avatar_url' => $this->imageService->getUrl(
                    $post->getAuthor()->getAvatarPath(),
                    width: 48,
                    format: 'webp',
                ),
            ],
            'images' => $this->formatImages($post->getImages()),
            'caption' => $post->getCaption(),
            'likes_count' => $post->getLikesCount(),
            'comments_count' => $post->getCommentsCount(),
            'is_liked' => $post->isLikedByCurrentUser(),
            'created_at' => $post->getCreatedAt()->format(\DATE_ATOM),
        ];
    }

    /**
     * Return multiple image sizes for responsive loading.
     */
    private function formatImages(array $images): array
    {
        return array_map(fn(Image $img) => [
            'id' => $img->getId(),
            'aspect_ratio' => $img->getAspectRatio(),
            'dominant_color' => $img->getDominantColor(), // For placeholder
            'blurhash' => $img->getBlurhash(), // Low-quality placeholder
            'srcset' => [
                'small' => $this->imageService->getUrl($img->getPath(), width: 320, format: 'webp'),
                'medium' => $this->imageService->getUrl($img->getPath(), width: 640, format: 'webp'),
                'large' => $this->imageService->getUrl($img->getPath(), width: 1080, format: 'webp'),
            ],
        ], $images);
    }
}
## Image Optimization

Стратегия оптимизации изображений

Техника Описание Эффект
Format selection WebP/AVIF вместо JPEG -30-50% размера
Responsive images srcset с разными размерами Загрузка по размеру экрана
Lazy loading Загрузка при приближении к viewport Экономия трафика
Placeholder BlurHash / dominant color Нет layout shift
Progressive JPEG Постепенная детализация Быстрое отображение
CDN caching Кеширование на edge Снижение latency

Image Processing Service

<?php

declare(strict_types=1);

namespace App\Service;

final readonly class ImageService
{
    public function __construct(
        private string $cdnBaseUrl,
        private string $imageTransformSecret,
    ) {}

    /**
     * Generate signed URL for image with transformations.
     * Uses CDN-side image processing (e.g., Cloudinary, Imgproxy).
     */
    public function getUrl(
        string $path,
        int $width,
        string $format = 'webp',
        int $quality = 80,
    ): string {
        $transforms = "w_{$width},f_{$format},q_{$quality}";
        $signature = $this->sign("{$transforms}/{$path}");

        return "{$this->cdnBaseUrl}/{$signature}/{$transforms}/{$path}";
    }

    /**
     * Generate BlurHash placeholder during upload.
     */
    public function generateBlurhash(string $imagePath): string
    {
        // Resize to tiny image for fast BlurHash computation
        $img = imagecreatefromstring(file_get_contents($imagePath));
        $small = imagecreatetruecolor(4, 4);
        imagecopyresampled($small, $img, 0, 0, 0, 0, 4, 4, imagesx($img), imagesy($img));

        // Extract pixel data for BlurHash
        $pixels = [];
        for ($y = 0; $y < 4; $y++) {
            for ($x = 0; $x < 4; $x++) {
                $rgb = imagecolorat($small, $x, $y);
                $pixels[] = [
                    ($rgb >> 16) & 0xFF,
                    ($rgb >> 8) & 0xFF,
                    $rgb & 0xFF,
                ];
            }
        }

        imagedestroy($img);
        imagedestroy($small);

        // In production, use kornrunner/blurhash library
        return $this->encodeBlurhash($pixels, 4, 3);
    }

    /**
     * Extract dominant color for CSS background placeholder.
     */
    public function getDominantColor(string $imagePath): string
    {
        $img = imagecreatefromstring(file_get_contents($imagePath));
        $pixel = imagecreatetruecolor(1, 1);
        imagecopyresampled($pixel, $img, 0, 0, 0, 0, 1, 1, imagesx($img), imagesy($img));

        $rgb = imagecolorat($pixel, 0, 0);
        imagedestroy($img);
        imagedestroy($pixel);

        return sprintf('#%06x', $rgb);
    }

    private function sign(string $path): string
    {
        return substr(hash_hmac('sha256', $path, $this->imageTransformSecret), 0, 16);
    }

    private function encodeBlurhash(array $pixels, int $xComponents, int $yComponents): string
    {
        // Simplified — use kornrunner/blurhash in production
        return 'LKO2:N%2Tw=w]~RBVZRi};RPxuwH';
    }
}
## Infinite Scroll

Стратегия загрузки

Viewport:
┌──────────────────┐
│  Post 1 (видим)  │
│  Post 2 (видим)  │
│  Post 3 (видим)  │
├──────────────────┤ ← viewport bottom
│  Post 4          │ ← trigger zone (2 posts from bottom)
│  Post 5          │
│  [Loading...]    │ ← fetch next page when trigger reaches viewport
└──────────────────┘

Feed Service с курсорной пагинацией

<?php

declare(strict_types=1);

namespace App\Service;

use Doctrine\DBAL\Connection;

final readonly class FeedService
{
    public function __construct(
        private Connection $db,
        private RecommendationClient $recommendations,
    ) {}

    /**
     * Get feed items using cursor-based pagination.
     * Cursor is the created_at timestamp of the last seen post.
     */
    public function getFeed(string $userId, ?string $cursor, int $limit): FeedResult
    {
        $params = ['userId' => $userId, 'limit' => $limit + 1];

        $sql = 'SELECT p.*, u.username, u.avatar_path
                FROM posts p
                JOIN users u ON p.author_id = u.id
                JOIN follows f ON f.followed_id = p.author_id
                WHERE f.follower_id = :userId';

        if ($cursor !== null) {
            $sql .= ' AND p.created_at < :cursor';
            $params['cursor'] = $cursor;
        }

        $sql .= ' ORDER BY p.created_at DESC LIMIT :limit';

        $rows = $this->db->fetchAllAssociative($sql, $params);

        $hasMore = count($rows) > $limit;
        $posts = array_slice($rows, 0, $limit);
        $nextCursor = $hasMore ? end($posts)['created_at'] : null;

        return new FeedResult(
            posts: array_map($this->hydrate(...), $posts),
            nextCursor: $nextCursor,
            hasMore: $hasMore,
        );
    }

    private function hydrate(array $row): Post
    {
        // Hydrate Post entity from DB row
        return new Post(/* ... */);
    }
}

final readonly class FeedResult
{
    public function __construct(
        /** @var array<Post> */
        public array $posts,
        public ?string $nextCursor,
        public bool $hasMore,
    ) {}
}
## Virtual Scrolling

Virtual scrolling (windowing) -- рендер только видимых элементов. Критически важен для длинных лент.

Принцип работы

Без virtualization:       С virtualization:
DOM: 1000 элементов       DOM: ~15 элементов (видимые + буфер)

Scroll position: 5000px

┌─────────────────┐       ┌─────────────────┐
│ spacer (top)    │       │ spacer: 4800px  │ ← empty div
├─────────────────┤       ├─────────────────┤
│ Post 50 (видим) │       │ Post 50 (видим) │
│ Post 51 (видим) │       │ Post 51 (видим) │
│ Post 52 (видим) │       │ Post 52 (видим) │
├─────────────────┤       ├─────────────────┤
│ spacer (bottom) │       │ spacer: 95000px │ ← empty div
└─────────────────┘       └─────────────────┘

Memory: ~500MB             Memory: ~15MB

Оффлайн-кеширование

API для кеширования на клиенте

<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;

final readonly class FeedCacheController
{
    public function __construct(
        private FeedService $feedService,
    ) {}

    /**
     * Endpoint for sync — returns only posts newer than last sync.
     * Client stores posts in IndexedDB for offline access.
     */
    #[Route('/api/v1/feed/sync', methods: ['GET'])]
    public function sync(Request $request): JsonResponse
    {
        $userId = $request->attributes->get('auth_user_id');
        $lastSyncTimestamp = $request->query->get('since');

        $newPosts = $this->feedService->getPostsSince($userId, $lastSyncTimestamp);

        $response = new JsonResponse([
            'posts' => $newPosts,
            'sync_timestamp' => (new \DateTimeImmutable())->format(\DATE_ATOM),
            'total_new' => count($newPosts),
        ]);

        // ETag for conditional requests — saves bandwidth
        $etag = md5(json_encode($newPosts));
        $response->setEtag($etag);

        if ($request->headers->get('If-None-Match') === "\"{$etag}\"") {
            $response->setStatusCode(304);
            $response->setContent('');
        }

        return $response;
    }
}
## Performance Budget
Ресурс Бюджет Примечание
HTML < 14KB (initial) Первый TCP roundtrip
CSS < 50KB Critical CSS inline
JS (initial) < 150KB (gzipped) Для первого рендера
JS (total) < 500KB (gzipped) Все чанки
Images (per post) < 200KB WebP, quality 80
Fonts < 50KB WOFF2, subset
Total initial load < 300KB Включая HTML+CSS+JS

Итоги

Концепция Суть
Cursor pagination Стабильная пагинация для бесконечного скролла
Image optimization WebP, srcset, BlurHash, CDN
Virtual scrolling Рендер только видимых элементов
Lazy loading Загрузка изображений при приближении к viewport
Offline caching IndexedDB + Service Worker для оффлайн-доступа
Performance budget Жёсткие лимиты на размер ресурсов

Ключевой урок: Производительность ленты определяется не скоростью отдельного запроса, а ощущением бесконечной и мгновенной прокрутки. Это достигается предзагрузкой, виртуализацией и оптимизацией изображений.