Guide 2026-04-26

Advanced Inventory Management Tips for FiveM

TDYSKY

TDYSKY

Founder & Lead Developer at Agency Scripts

Why Inventory Architecture Matters

The inventory system is the backbone of any FiveM roleplay server. Every player interaction with items, from picking up a weapon to handing someone a key, flows through the inventory. A poorly designed inventory leads to item duplication exploits, desync issues, and frustrated players who lose their gear. The most robust inventory systems follow a server-authoritative model where the client only displays what the server tells it, never trusting the client to report its own state. This architectural decision alone eliminates the majority of duplication exploits that plague servers with client-trusted inventories. When planning your inventory, think about the data flow first: the server owns the truth, the client renders it, and every mutation goes through a validated server event.

Weight vs Slot-Based Systems

Choosing between weight-based and slot-based inventory systems fundamentally shapes the player experience. In a slot-based system, each slot holds one item type with a maximum stack size, and the total number of slots defines carrying capacity. In a weight-based system, every item has a weight value, and the player has a maximum carry weight. Many modern frameworks combine both approaches, using slots for organization but limiting total capacity by weight. Here is an example of a combined weight-and-slot item definition:

-- Shared item definitions (items.lua)
QBCore.Shared.Items = {
    ['water_bottle'] = {
        name = 'water_bottle',
        label = 'Water Bottle',
        weight = 500,        -- grams
        type = 'item',
        image = 'water_bottle.png',
        unique = false,
        useable = true,
        shouldClose = true,
        description = 'A refreshing bottle of water',
        stackSize = 10,      -- max per slot
    },
    ['lockpick'] = {
        name = 'lockpick',
        label = 'Lockpick',
        weight = 200,
        type = 'item',
        image = 'lockpick.png',
        unique = false,
        useable = true,
        shouldClose = true,
        description = 'Used to pick locks',
        stackSize = 5,
    },
}

The weight field is stored in grams for precision, and the stackSize controls how many of that item fit in a single slot. When a player tries to pick up an item, validate both that a slot is available and that the total weight would not exceed the maximum. This dual validation prevents players from carrying unrealistic amounts of heavy items even if they have empty slots.

Server-Side Validation and Anti-Exploit

Every inventory action must be validated on the server before it takes effect. When a player drags an item from slot 3 to slot 7, the client sends a move request, and the server verifies that the source slot actually contains that item, the destination slot can accept it, and the quantities are consistent. Never let the client specify item counts or create items from nothing. Here is a secure server-side move handler:

RegisterNetEvent('inventory:server:moveItem', function(fromSlot, toSlot, fromAmount)
    local src = source
    local Player = QBCore.Functions.GetPlayer(src)
    if not Player then return end

    local fromItem = Player.PlayerData.items[fromSlot]
    if not fromItem then
        -- Source slot is empty, possible exploit attempt
        DropPlayer(src, 'Invalid inventory operation')
        return
    end

    if fromAmount > fromItem.amount or fromAmount < 1 then
        DropPlayer(src, 'Invalid inventory amount')
        return
    end

    local toItem = Player.PlayerData.items[toSlot]

    if toItem and toItem.name == fromItem.name and not fromItem.unique then
        -- Stack items together
        local maxStack = QBCore.Shared.Items[fromItem.name].stackSize or 50
        local canStack = maxStack - toItem.amount
        local moveAmount = math.min(fromAmount, canStack)

        if moveAmount > 0 then
            toItem.amount = toItem.amount + moveAmount
            fromItem.amount = fromItem.amount - moveAmount
            if fromItem.amount <= 0 then
                Player.PlayerData.items[fromSlot] = nil
            end
        end
    else
        -- Swap items between slots
        Player.PlayerData.items[toSlot] = fromItem
        Player.PlayerData.items[fromSlot] = toItem
    end

    Player.Functions.SetPlayerData('items', Player.PlayerData.items)
end)

Notice the DropPlayer calls for clearly impossible operations. Logging these events to a separate audit table helps you identify exploit attempts and patterns. Consider implementing rate limiting on inventory events as well, because legitimate players rarely perform more than a few inventory operations per second, while automated exploits often send hundreds of requests rapidly.

Item Metadata and Unique Items

Metadata transforms simple items into rich, unique objects. A weapon can carry its serial number, durability, and attached modifications. A phone can store its assigned number and contact list reference. Food items can have an expiration timestamp. Metadata is stored as a Lua table serialized to JSON in the database, attached to each item instance. The key distinction is between stackable items, which share the same metadata, and unique items, which each get their own metadata table. Here is how to create a weapon with full metadata:

-- Creating a weapon with metadata
function CreateWeaponItem(src, weaponName, serial)
    local Player = QBCore.Functions.GetPlayer(src)
    if not Player then return false end

    local metadata = {
        serial = serial or GenerateSerial(),
        durability = 100.0,
        ammo = 0,
        attachments = {},
        registered = false,
        registeredTo = nil,
        quality = math.random(85, 100),
        created = os.time(),
    }

    return Player.Functions.AddItem(weaponName, 1, nil, metadata)
end

function GenerateSerial()
    local chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
    local serial = ''
    for i = 1, 10 do
        local idx = math.random(1, #chars)
        serial = serial .. chars:sub(idx, idx)
    end
    return serial
end

When displaying items with metadata in the NUI, pass the metadata alongside the item info so the UI can show details like durability bars, serial numbers, and quality ratings. This gives players a deeper connection to their items and supports advanced roleplay scenarios like weapon registration systems or forensic investigations tracing a serial number back to its owner.

Drag-and-Drop NUI Performance

The inventory UI is one of the most performance-sensitive NUI elements because players interact with it constantly. Avoid re-rendering the entire inventory grid on every update. Instead, use a virtual DOM approach or targeted element updates that only modify the slots that changed. When a player drags an item, handle the drag entirely in JavaScript using mouse events rather than sending position updates to the Lua client during the drag. Only send the final drop result as a single NUI callback. Here is a performant drag handler pattern:

// Inventory NUI - performant drag and drop
let draggedItem = null;
let dragElement = null;

document.addEventListener('mousedown', (e) => {
    const slot = e.target.closest('.inv-slot[data-has-item="true"]');
    if (!slot) return;

    draggedItem = {
        slot: parseInt(slot.dataset.slot),
        item: JSON.parse(slot.dataset.itemInfo),
    };

    dragElement = slot.cloneNode(true);
    dragElement.classList.add('dragging-ghost');
    dragElement.style.position = 'fixed';
    dragElement.style.pointerEvents = 'none';
    dragElement.style.zIndex = '9999';
    document.body.appendChild(dragElement);

    moveDragElement(e.clientX, e.clientY);
});

document.addEventListener('mousemove', (e) => {
    if (!dragElement) return;
    moveDragElement(e.clientX, e.clientY);
});

document.addEventListener('mouseup', (e) => {
    if (!draggedItem) return;

    const targetSlot = e.target.closest('.inv-slot');
    if (targetSlot) {
        const toSlot = parseInt(targetSlot.dataset.slot);
        // Send only the final result to Lua
        fetch(`https://${GetParentResourceName()}/moveItem`, {
            method: 'POST',
            body: JSON.stringify({
                fromSlot: draggedItem.slot,
                toSlot: toSlot,
                amount: draggedItem.item.amount,
            }),
        });
    }

    if (dragElement) dragElement.remove();
    draggedItem = null;
    dragElement = null;
});

For the visual rendering, use CSS Grid for the slot layout and avoid heavy CSS animations on inventory items since players may have dozens of slots visible simultaneously. Image sprites for item icons load faster than individual image files and reduce HTTP requests when the NUI first opens.

Stash and Container Systems

Beyond personal inventory, players need access to external storage like vehicle trunks, house stashes, and shared organization storage. Each container type should have its own capacity limits and access control rules. Vehicle trunks use the vehicle plate as a unique identifier, house stashes use property IDs, and job stashes use the job name combined with a grade check. Store container inventories in a dedicated database table separate from player inventories to keep queries efficient:

CREATE TABLE IF NOT EXISTS stash_items (
    id INT AUTO_INCREMENT PRIMARY KEY,
    stash_id VARCHAR(100) NOT NULL,
    slot INT NOT NULL,
    item_name VARCHAR(50) NOT NULL,
    amount INT DEFAULT 1,
    metadata LONGTEXT DEFAULT '{}',
    UNIQUE KEY unique_stash_slot (stash_id, slot),
    INDEX idx_stash_id (stash_id)
);

-- Example stash_id values:
-- 'trunk_ABC123'        (vehicle trunk by plate)
-- 'house_42'            (house stash by property id)
-- 'police_evidence_1'   (job stash with identifier)

When a player opens a container, load its contents from the database and lock it to prevent simultaneous access by multiple players. Use a server-side lock table that tracks which stash IDs are currently open and by whom. Release the lock when the player closes the container or disconnects. This prevents the classic duplication exploit where two players open the same trunk and both withdraw the same items.

Inventory Synchronization and Persistence

Keeping inventory data synchronized between the server memory, the database, and the client display requires careful coordination. Save player inventory to the database periodically, not on every single item change, to reduce database write load. A save interval of 30 to 60 seconds works well for most servers. Additionally, always save on player disconnect and on server shutdown using the playerDropped event and a shutdown handler. Implement a dirty flag system that only writes to the database when the inventory has actually changed since the last save:

local inventoryDirty = {}

-- Mark inventory as needing save
function MarkDirty(citizenid)
    inventoryDirty[citizenid] = true
end

-- Periodic save loop
CreateThread(function()
    while true do
        Wait(30000) -- 30 seconds
        for citizenid, dirty in pairs(inventoryDirty) do
            if dirty then
                local Player = QBCore.Functions.GetPlayerByCitizenId(citizenid)
                if Player then
                    SaveInventoryToDatabase(citizenid, Player.PlayerData.items)
                end
                inventoryDirty[citizenid] = nil
            end
        end
    end
end)

AddEventHandler('playerDropped', function()
    local src = source
    local Player = QBCore.Functions.GetPlayer(src)
    if Player then
        local citizenid = Player.PlayerData.citizenid
        if inventoryDirty[citizenid] then
            SaveInventoryToDatabase(citizenid, Player.PlayerData.items)
            inventoryDirty[citizenid] = nil
        end
    end
end)

For the client side, batch NUI updates so that multiple rapid inventory changes, such as receiving several items from a crafting operation, result in a single UI refresh rather than one per item. This eliminates the flickering effect that players see when items appear one at a time in their inventory grid.

Performance Monitoring and Optimization

Monitor your inventory system performance by tracking key metrics: average database save time, event processing rate, and NUI render frequency. Use the FiveM profiler to identify bottlenecks in your inventory callbacks. Common performance traps include iterating over all player inventories every frame for proximity-based features like ground item pickups, serializing large metadata objects unnecessarily, and leaving NUI frames active when the inventory is closed. For ground items, use a spatial grid system that only checks items within the player cell rather than scanning every dropped item on the server. Cache item definitions in a lookup table indexed by item name so that fetching item properties is an O(1) hash lookup rather than an O(n) array scan. Finally, consider implementing inventory pagination for containers with very large capacities, loading only the visible slots and fetching additional rows as the player scrolls, which keeps both database queries and NUI rendering lightweight even for warehouses with hundreds of slots.

Share this article

Ready to upgrade your server?

Check out our premium FiveM scripts in the Agency Scripts store or join our Discord community for support and updates.